#[allow(clippy::wildcard_imports)]
use super::*;
pub fn run(config: &Config, store: &Store) -> Result<bool> {
run_with(config, store, config.lint_foreign_repositories)
}
pub fn run_with(config: &Config, store: &Store, foreign: bool) -> Result<bool> {
let findings = collect_with(config, store, foreign)?;
if findings.is_empty() {
println!("Lint OK: {} issues checked.", issue_files(store)?.len());
return Ok(false);
}
for finding in &findings {
eprintln!("{}", render_diagnostic(&store.issues_dir, finding));
}
let errors = findings
.iter()
.filter(|f| f.severity == Severity::Error)
.count();
let warnings = findings.len() - errors;
if errors == 0 {
println!(
"Lint OK with {warnings} warning{}: {} issues checked.",
if warnings == 1 { "" } else { "s" },
issue_files(store)?.len()
);
}
Ok(errors > 0)
}
pub fn collect(config: &Config, store: &Store) -> Result<Vec<TixIssueFinding>> {
collect_with(config, store, config.lint_foreign_repositories)
}
pub fn collect_with(config: &Config, store: &Store, foreign: bool) -> Result<Vec<TixIssueFinding>> {
let mut findings = collect_local(config, store)?;
if foreign {
let mut visited = HashSet::new();
if let Some(root) = Config::project_root(&store.issues_dir) {
visited.insert(root);
}
collect_foreign(store, &mut visited, &mut findings);
}
Ok(findings)
}
pub(super) fn collect_local(config: &Config, store: &Store) -> Result<Vec<TixIssueFinding>> {
let files = issue_files(store)?;
let planner = planner_for(config);
let milestone_names = planner.milestone_names().unwrap_or_default();
let mut findings: Vec<TixIssueFinding> = Vec::new();
let mut parsed: Vec<ParsedFile> = Vec::new();
for rel in &files {
if let Some(entry) = collect_for_file(
config,
store,
&planner,
&milestone_names,
rel,
&mut findings,
)? {
parsed.push(entry);
}
}
collect_duplicate_numbers(&parsed, &mut findings);
collect_relationships(&parsed, store, &mut findings);
collect_hierarchy(&parsed, &mut findings);
collect_project_references(config, &parsed, &mut findings);
collect_epic_references(config, &planner, &parsed, &mut findings);
collect_related_issues(&parsed, store, &mut findings);
collect_layout_mismatch(config, &parsed, &mut findings);
collect_workflow_config(config, &mut findings);
collect_workflow_direct_edits(config, &parsed, &mut findings);
collect_wip(config, &parsed, &mut findings);
Ok(findings)
}
pub(super) fn collect_duplicate_numbers(
parsed: &[ParsedFile],
findings: &mut Vec<TixIssueFinding>,
) {
let mut by_number: BTreeMap<u32, Vec<&ParsedFile>> = BTreeMap::new();
for entry in parsed {
by_number.entry(entry.issue.number).or_default().push(entry);
}
for (number, mut entries) in by_number {
if entries.len() < 2 {
continue;
}
entries.sort_by(|a, b| a.rel.cmp(&b.rel));
let files = entries
.iter()
.map(|entry| entry.rel.as_str())
.collect::<Vec<_>>()
.join(", ");
for entry in entries {
findings.push(TixIssueFinding {
severity: Severity::Error,
file: entry.rel.clone(),
line: locate_key_line(&entry.text, "number"),
column: 1,
value: number.to_string(),
kind: "duplicate_number".to_string(),
message: format!("number {number} is shared by {files}"),
});
}
}
}
pub(super) fn collect_wip(
config: &Config,
parsed: &[ParsedFile],
findings: &mut Vec<TixIssueFinding>,
) {
if parsed.is_empty() {
return;
}
let issues: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
let snapshot = crate::wip::WipSnapshot::build(config.default_config(), &issues);
if !snapshot.is_configured() {
return;
}
for usage in snapshot.violations() {
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: String::new(),
line: 0,
column: 0,
value: usage.cell(),
kind: crate::wip::WipDiagnosticKind::OverLimit
.as_str()
.to_string(),
message: format!(
"`{}` holds {} against {}",
usage.column,
usage.cell(),
usage
.limit
.as_ref()
.map_or_else(String::new, describe_wip_limit)
),
});
}
for diagnostic in snapshot.diagnostics() {
let Some(entry) = parsed.iter().find(|parsed| {
diagnostic
.message
.contains(&format!("#{}", parsed.issue.number))
}) else {
continue;
};
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: entry.rel.clone(),
line: 0,
column: 0,
value: entry.issue.estimate.clone().unwrap_or_default(),
kind: diagnostic.kind.as_str().to_string(),
message: diagnostic.message.clone(),
});
}
}
pub(super) fn describe_wip_limit(limit: &crate::wip::ColumnLimit) -> String {
let mut parts = Vec::new();
if let Some(max) = limit.max_items {
parts.push(format!("max_items {max}"));
}
if let Some(max) = limit.max_estimate {
match limit.unit.as_deref() {
Some(unit) => parts.push(format!("max_estimate {max}{unit}")),
None => parts.push(format!("max_estimate {max}")),
}
}
match limit.policy {
Some(policy) => parts.push(policy.as_str().to_string()),
None => parts.push("no policy".to_string()),
}
parts.join(", ")
}
pub(super) fn collect_foreign(
store: &Store,
visited: &mut HashSet<PathBuf>,
findings: &mut Vec<TixIssueFinding>,
) {
const MAX_REPOS: usize = 64;
let Ok(issues) = store.list_all() else {
return;
};
let mut candidates: Vec<PathBuf> = Vec::new();
let mut remote: Vec<String> = Vec::new();
let mut missing: Vec<String> = Vec::new();
for issue in &issues {
let base = related::base_dir(&store.issues_dir, &issue.filename);
for r in related::classify_all(&issue.related_issues) {
match &r {
RelatedRef::Path { .. } => {
if let Some(target) = related::resolve_path(&base, &r) {
if !target.exists() {
missing.push(r.display());
continue;
}
let start = if target.is_dir() {
target
} else {
target
.parent()
.map_or_else(|| target.clone(), Path::to_path_buf)
};
if let Some(root) = Config::project_root(&start) {
candidates.push(root);
}
}
}
RelatedRef::Url { .. } => remote.push(r.display()),
RelatedRef::Local(_) => {}
}
}
}
candidates.sort();
candidates.dedup();
let mut linted = 0usize;
for root in candidates {
if linted >= MAX_REPOS {
break;
}
if visited.contains(&root) {
continue;
}
visited.insert(root.clone());
match lint_foreign_repo(&root) {
Ok((label, mut foreign)) => {
for f in &mut foreign {
f.file = if f.file.is_empty() {
format!("[{label}]")
} else {
format!("[{label}] {}", f.file)
};
}
findings.extend(foreign);
linted += 1;
}
Err(reason) => findings.push(foreign_note(root.to_string_lossy().to_string(), reason)),
}
}
remote.sort();
remote.dedup();
for url in remote {
findings.push(foreign_note(
url.clone(),
format!("{url}: not linted (remote)"),
));
}
missing.sort();
missing.dedup();
for path in missing {
findings.push(foreign_note(
path.clone(),
format!("{path}: related path does not exist"),
));
}
}
pub(super) fn lint_foreign_repo(
root: &Path,
) -> std::result::Result<(String, Vec<TixIssueFinding>), String> {
let cfg_path = root.join("tix.toml");
let mut cfg = Config::load_with_warning(&cfg_path, |_| {})
.map_err(|e| format!("{}: not linted ({e})", root.display()))?;
if cfg.issues_dir.is_relative() {
cfg.issues_dir = root.join(&cfg.issues_dir);
}
if !cfg.issues_dir.is_dir() {
return Err(format!(
"{}: not linted (issues dir {} is not a directory)",
root.display(),
cfg.issues_dir.display()
));
}
let store = Store::open(&cfg).map_err(|e| format!("{}: not linted ({e})", root.display()))?;
let foreign =
collect_local(&cfg, &store).map_err(|e| format!("{}: not linted ({e})", root.display()))?;
let label = root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("foreign")
.to_string();
Ok((label, foreign))
}
pub(super) fn foreign_note(value: String, message: String) -> TixIssueFinding {
TixIssueFinding {
severity: Severity::Warning,
file: String::new(),
line: 0,
column: 0,
value,
kind: "foreign_repo_not_linted".to_string(),
message,
}
}
pub(super) fn collect_workflow_config(config: &Config, findings: &mut Vec<TixIssueFinding>) {
let mut by_board: BTreeMap<String, Vec<String>> = BTreeMap::new();
for diagnostic in validate_config(config) {
match diagnostic.kind {
DiagnosticKind::UnmappedColumnDefault => {
let (board, column) = match diagnostic.subject.rsplit_once('.') {
Some((board, column)) => (board.to_string(), column.to_string()),
None => (diagnostic.subject.clone(), String::new()),
};
by_board.entry(board).or_default().push(column);
}
_ => findings.push(TixIssueFinding {
severity: Severity::Error,
file: String::new(),
line: 0,
column: 0,
value: diagnostic.subject.clone(),
kind: diagnostic.kind.as_str().to_string(),
message: diagnostic.message,
}),
}
}
for (board, columns) in by_board {
let count = columns.len();
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: String::new(),
line: 0,
column: 0,
value: board.clone(),
kind: DiagnosticKind::UnmappedColumnDefault.as_str().to_string(),
message: format!(
"{count} of {count} column(s) on board `{board}` have no `category_map` \
entry and resolve to the safe default `{}`: {}. This is valid \
configuration — it is what every repository has — and it does not \
affect the exit code. To map them, add a \
[kanban.{board}.category_map] table.",
config.categories.default,
columns.join(", ")
),
});
}
}
pub(super) fn collect_workflow_direct_edits(
config: &Config,
parsed: &[ParsedFile],
findings: &mut Vec<TixIssueFinding>,
) {
if parsed.is_empty() {
return;
}
let (_, resolution) = crate::workflow::active_resolution(config);
let issues: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
for finding in crate::workflow::lint_direct_edits(&issues, &resolution, config) {
let Some(entry) = parsed.iter().find(|p| p.issue.number == finding.number) else {
continue;
};
let value = match &finding.recorded {
Some(recorded) => format!("{recorded} -> {}", finding.current),
None => finding.current.clone(),
};
findings.push(finding_at(
&entry.rel,
&entry.text,
finding.kind.as_str(),
"status",
&value,
finding.message,
));
}
}
pub(super) fn collect_hierarchy(parsed: &[ParsedFile], findings: &mut Vec<TixIssueFinding>) {
let by_number: BTreeMap<u32, &ParsedFile> =
parsed.iter().map(|p| (p.issue.number, p)).collect();
let by_file: BTreeMap<&str, &ParsedFile> = parsed.iter().map(|p| (p.rel.as_str(), p)).collect();
let snapshot: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
let result = crate::hierarchy::validate_hierarchy(&snapshot);
for diagnostic in &result.errors {
let kind = diagnostic.kind.as_str();
let source = diagnostic
.source_file
.as_deref()
.and_then(|filename| by_file.get(filename).copied())
.or_else(|| by_number.get(&diagnostic.issue).copied());
if let Some(entry) = source {
findings.push(finding_at(
&entry.rel,
&entry.text,
kind,
&diagnostic.field,
&entry
.issue
.parent
.map(|p| p.to_string())
.unwrap_or_default(),
diagnostic.message.clone(),
));
}
}
}
pub(super) fn collect_project_references(
config: &Config,
parsed: &[ParsedFile],
findings: &mut Vec<TixIssueFinding>,
) {
if parsed.is_empty() {
return;
}
let snapshot: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
let by_number: BTreeMap<u32, &ParsedFile> =
parsed.iter().map(|p| (p.issue.number, p)).collect();
for missing in crate::projects::validate_references(config, &snapshot) {
let Some(entry) = by_number.get(&missing.issue.number()) else {
continue;
};
findings.push(finding_at(
&entry.rel,
&entry.text,
crate::projects::MISSING_REFERENCE_KIND,
crate::projects::PROJECTS_KEY,
&missing.project_id,
missing.reason.clone(),
));
}
}
pub(super) fn collect_epic_references(
config: &Config,
planner: &Planner,
parsed: &[ParsedFile],
findings: &mut Vec<TixIssueFinding>,
) {
let Ok(targets) = crate::epics::ReferenceTargets::from_planner(planner) else {
return;
};
let issues: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
let by_number: BTreeMap<u32, &ParsedFile> =
parsed.iter().map(|p| (p.issue.number, p)).collect();
for missing in crate::epics::resolve_references(&config.epics, &issues, &targets) {
let kind = missing.target_kind.diagnostic_kind();
match &missing.holder {
crate::epics::ReferenceHolder::Issue(key) => {
let Some(entry) = by_number.get(&key.number()) else {
continue;
};
findings.push(finding_at(
&entry.rel,
&entry.text,
kind,
crate::epics::EPICS_KEY,
&missing.target,
missing.reason.clone(),
));
}
crate::epics::ReferenceHolder::Epic(_) => findings.push(TixIssueFinding {
severity: Severity::Error,
file: String::new(),
line: 0,
column: 0,
value: missing.target.clone(),
kind: kind.to_string(),
message: missing.reason.clone(),
}),
}
}
}
pub(super) fn collect_relationships(
parsed: &[ParsedFile],
store: &Store,
findings: &mut Vec<TixIssueFinding>,
) {
let done_column = store.relationship_done_column();
let by_number: BTreeMap<u32, &ParsedFile> =
parsed.iter().map(|p| (p.issue.number, p)).collect();
let by_file: BTreeMap<&str, &ParsedFile> = parsed.iter().map(|p| (p.rel.as_str(), p)).collect();
let views: Vec<IssueView<'_>> = parsed.iter().map(|p| p.issue.issue_view()).collect();
let graph = RelationshipGraph::new(&views, &done_column);
let result = graph.validate_snapshot();
for diagnostic in &result.errors {
let kind = diagnostic.kind.as_str();
let source = diagnostic
.source_file
.as_deref()
.and_then(|filename| by_file.get(filename).copied())
.or_else(|| diagnostic.issue.and_then(|n| by_number.get(&n).copied()));
match source {
Some(entry) => findings.push(finding_at(
&entry.rel,
&entry.text,
kind,
&diagnostic.field,
&diagnostic.field,
diagnostic.message.clone(),
)),
None => findings.push(TixIssueFinding {
severity: Severity::Error,
file: String::new(),
line: 0,
column: 0,
value: diagnostic.field.clone(),
kind: kind.to_string(),
message: diagnostic.message.clone(),
}),
}
}
}
pub(super) fn collect_related_issues(
parsed: &[ParsedFile],
store: &Store,
findings: &mut Vec<TixIssueFinding>,
) {
for entry in parsed {
for raw in &entry.issue.related_issues {
let kind = if raw.trim().is_empty() {
Some("related_issue_empty")
} else {
let r = crate::related::classify(raw);
match r.local_selector() {
Some(_) if crate::related::resolve_local(store, raw).is_err() => {
Some("related_issue_unresolved")
}
_ => None,
}
};
let Some(kind) = kind else { continue };
let (line, column) =
locate_value(&entry.text, "related_issues").map_or((0, 0), |l| (l.line, l.column));
let message = match kind {
"related_issue_empty" => {
"empty related-issue entry; remove it or name an issue".to_string()
}
_ => format!(
"related issue `{raw}` matches no issue in this store; it may be a \
path or URL (which lint does not resolve) or a typo"
),
};
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: entry.rel.clone(),
line,
column,
value: raw.clone(),
kind: kind.to_string(),
message,
});
}
}
}
pub(super) fn collect_layout_mismatch(
config: &Config,
parsed: &[ParsedFile],
findings: &mut Vec<TixIssueFinding>,
) {
let default = crate::config::LayoutConfig::default();
if config.layout.directory == default.directory && config.layout.filename == default.filename {
return;
}
for entry in parsed {
let expected = crate::layout::render_directory(&config.layout, &entry.issue);
let current = entry
.issue
.filename
.rsplit_once('/')
.map_or("", |(dir, _)| dir);
if current != expected {
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: entry.rel.clone(),
line: 0,
column: 0,
value: entry.issue.filename.clone(),
kind: "layout_mismatch".to_string(),
message: format!(
"directory does not match `[layout]`: expected `{expected}`; run `tix layout migrate`"
),
});
}
}
}
pub fn collect_file(config: &Config, store: &Store, rel: &str) -> Result<Vec<TixIssueFinding>> {
let planner = planner_for(config);
let milestone_names = planner.milestone_names().unwrap_or_default();
let mut findings = Vec::new();
collect_for_file(
config,
store,
&planner,
&milestone_names,
rel,
&mut findings,
)?;
Ok(findings)
}
pub(super) fn issue_files(store: &Store) -> Result<Vec<String>> {
let mut files: Vec<String> = Vec::new();
collect_issue_files(&store.issues_dir, "", &mut files)?;
files.sort();
Ok(files)
}
pub(super) fn collect_issue_files(dir: &Path, prefix: &str, files: &mut Vec<String>) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
let path = entry.path();
if path.is_dir() {
if name.starts_with('.') || name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
continue;
}
let child = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
collect_issue_files(&path, &child, files)?;
} else if path.is_file() && name.ends_with(".md") {
files.push(if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
});
}
}
Ok(())
}