From ff95165447defd71a16a99f05cb4399bdd0edc00 Mon Sep 17 00:00:00 2001 From: Frank O'Hara Date: Thu, 13 Aug 2026 09:27:51 -0600 Subject: [PATCH 1/2] fix(cli): surface .lashignore and explain the linter's own codes (#58) The W_INDEX_ORPHAN message, both --help surfaces and the docs now name .lashignore, and `lash explain` knows every code lint emits. --- CHANGELOG.md | 20 + README.md | 22 +- crates/lash-agent/src/content.rs | 2 +- crates/lash-cli/src/cli.rs | 17 +- crates/lash-cli/src/commands/explain.rs | 259 +++++-- crates/lash-cli/src/commands/lint.rs | 107 +++ ...regression_tests__agent_prompt_output.snap | 2 +- ...on_tests__error_invalid_status_stdout.snap | 1 + ...ession_tests__error_missing_id_stdout.snap | 3 +- ...gression_tests__lint_200_tasks_stdout.snap | 3 +- ...sion_tests__lint_deeply_nested_stdout.snap | 17 +- ...ssion_tests__lint_flat_project_stdout.snap | 17 +- ...ion_tests__lint_medium_project_stdout.snap | 13 +- ...on_tests__lint_mixed_structure_stdout.snap | 13 +- ...sion_tests__lint_small_project_stdout.snap | 5 +- ...n_tests__lint_unicode_filename_stdout.snap | 3 +- crates/lash-core/src/linter/registry.rs | 31 + .../linter/rules/crossfile/orphaned_files.rs | 55 +- crates/lash-types/src/error.rs | 69 ++ crates/lash-types/src/error_explanations.rs | 661 ------------------ .../src/error_explanations/creation.rs | 180 +++++ .../src/error_explanations/crossfile.rs | 74 ++ .../src/error_explanations/legacy_lint.rs | 96 +++ .../lash-types/src/error_explanations/mod.rs | 217 ++++++ .../src/error_explanations/parse.rs | 80 +++ .../src/error_explanations/runtime.rs | 227 ++++++ .../src/error_explanations/semantic.rs | 206 ++++++ .../src/error_explanations/syntax.rs | 107 +++ devlog.md | 53 ++ docs/error-codes.md | 95 +++ docs/user-guide.md | 43 ++ lash.index.md | 19 + 32 files changed, 1966 insertions(+), 751 deletions(-) delete mode 100644 crates/lash-types/src/error_explanations.rs create mode 100644 crates/lash-types/src/error_explanations/creation.rs create mode 100644 crates/lash-types/src/error_explanations/crossfile.rs create mode 100644 crates/lash-types/src/error_explanations/legacy_lint.rs create mode 100644 crates/lash-types/src/error_explanations/mod.rs create mode 100644 crates/lash-types/src/error_explanations/parse.rs create mode 100644 crates/lash-types/src/error_explanations/runtime.rs create mode 100644 crates/lash-types/src/error_explanations/semantic.rs create mode 100644 crates/lash-types/src/error_explanations/syntax.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dcc8559..02ca706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ While the major version is 0, minor version bumps may contain breaking changes. ## [Unreleased] +### Added + +- `lash explain` now knows every code `lash lint` emits. The per-rule syntax, + semantic and cross-file codes (`E_SYNTAX_*`, `E_SEM_*`, `E_NOTE_*`, + `E_LINK_*`, `W_INDEX_ORPHAN`, and the `W_`/`I_` variants) previously answered + "Unknown error code", so following the advice in lint's own output was a dead + end. Warnings and info-level codes are also labelled as such rather than + introduced as errors. +- Lint's summary names one of the codes it just reported alongside the + `lash explain` invocation for it. + +### Fixed + +- `.lashignore` is reachable from where users hit it. The `W_INDEX_ORPHAN` + warning names it in the message text, `lash lint --help` and `lash --help` + describe file discovery, and the README, user guide and error-code reference + document it. The mechanism already worked; nothing pointed at it (#58). +- `lash explain --list` no longer drops codes whose prefix matched no category. + Every `W_` and `I_` code was silently missing from the listing. + ## [0.4.0] - 2026-08-11 A task's ID is derived from its title rather than stored, so a change to the diff --git a/README.md b/README.md index 10675f3..58c42da 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ This project follows strict quality standards: - **Pre-commit hooks**: Auto-enforces formatting, linting, and tests - **Zero warnings**: All clippy lints must pass with `clippy::pedantic` - **Comprehensive tests**: 3,000+ tests across all crates (>80% coverage target) -- **Error taxonomy**: 50+ documented error codes in `docs/error-codes.md` +- **Error taxonomy**: 75+ documented error codes in `docs/error-codes.md`, all queryable with `lash explain` - **Doctests**: All public APIs include executable examples - **CI/CD**: Automated testing on Linux, macOS, and Windows @@ -264,8 +264,28 @@ lash lint [PATH...] [--fix] [--interactive] # Format task files (alias: fmt) lash format [PATH...] [--check] [--diff] + +# Explain any code the linter reports +lash explain W_INDEX_ORPHAN +lash explain --list ``` +### Excluding Files + +Commands that walk the project (`lint`, `format`, `index`, `check-index`, +`check-links`) read every `.md` file under the project root, skipping anything +excluded by `.gitignore` or by a `.lashignore` at the project root. +`.lashignore` uses `.gitignore` syntax, so a directory of Markdown that is not +task files is one line: + +```bash +printf 'content/\n' >> .lashignore +``` + +Without it, each such file is reported once per run as `W_INDEX_ORPHAN` ("not +referenced in the root index"). Common documentation filenames and the `docs/` +directory are exempt already. + ### Indexing & Database ```bash diff --git a/crates/lash-agent/src/content.rs b/crates/lash-agent/src/content.rs index caaa6b5..92ed388 100644 --- a/crates/lash-agent/src/content.rs +++ b/crates/lash-agent/src/content.rs @@ -138,7 +138,7 @@ lash skill install --target T # Install Lash skill into a coding agent (claude lash config # Manage configuration (get, set, list, path) # Error Help -lash explain # Explain error code (e.g., E001) +lash explain # Explain any code lint reports (e.g., W_INDEX_ORPHAN) lash explain --list # List all error codes ``` diff --git a/crates/lash-cli/src/cli.rs b/crates/lash-cli/src/cli.rs index f802af3..5db0421 100644 --- a/crates/lash-cli/src/cli.rs +++ b/crates/lash-cli/src/cli.rs @@ -22,6 +22,11 @@ const LOGO_FOR_HELP: &str = "\ about = "Minimalist Markdown-native task tracker", long_about = "Lash is an ultra-fast, Markdown-native task tracker designed for developers and AI agents.\n\ It uses Markdown as the single source of truth and SQLite as an acceleration layer.\n\n\ + FILE DISCOVERY:\n \ + Commands that walk the project (lint, format, index, check-index) visit every .md\n \ + file under the project root, skipping anything excluded by .gitignore or by a\n \ + .lashignore file. .lashignore uses .gitignore syntax — one pattern per line, e.g.\n \ + 'content/' — and is the way to keep non-task Markdown out of Lash entirely.\n\n\ EXIT CODES:\n \ 0 - Success\n \ 1 - General error\n \ @@ -128,7 +133,17 @@ pub struct LashCli { #[allow(clippy::large_enum_variant)] pub enum Commands { /// Validate Lash task files for errors - #[command(alias = "check")] + #[command( + alias = "check", + long_about = "Validate Lash task files for errors.\n\n\ + With no PATH, lints every .md file under the project root, skipping anything\n\ + excluded by .gitignore or .lashignore. Add a .lashignore at the project root\n\ + (.gitignore syntax, one pattern per line, e.g. 'content/') to keep Markdown\n\ + that is not a task file out of linting — that is the fix for a directory of\n\ + prose reporting W_INDEX_ORPHAN once per file.\n\n\ + Run 'lash explain ' for a detailed explanation of any code reported\n\ + here, or 'lash explain --list' to see them all." + )] Lint { /// Files or directories to lint (defaults to current project) #[arg(value_name = "PATH")] diff --git a/crates/lash-cli/src/commands/explain.rs b/crates/lash-cli/src/commands/explain.rs index 5c5d190..0ac99b2 100644 --- a/crates/lash-cli/src/commands/explain.rs +++ b/crates/lash-cli/src/commands/explain.rs @@ -91,53 +91,10 @@ fn list_error_codes(args: &ExplainArgs, theme: Option<&CliTheme>) -> Result } println!(); - // Group by category - let mut parse_errors = Vec::new(); - let mut lint_errors = Vec::new(); - let mut dep_errors = Vec::new(); - let mut index_errors = Vec::new(); - let mut query_errors = Vec::new(); - let mut config_errors = Vec::new(); - let mut io_errors = Vec::new(); - let mut create_errors = Vec::new(); - let mut internal_errors = Vec::new(); - let mut semantic_warnings = Vec::new(); - - for code in &codes { - if code.starts_with("E_PARSE") { - parse_errors.push(*code); - } else if code.starts_with("E_LINT") { - lint_errors.push(*code); - } else if code.starts_with("E_DEP") { - dep_errors.push(*code); - } else if code.starts_with("E_INDEX") { - index_errors.push(*code); - } else if code.starts_with("E_QUERY") { - query_errors.push(*code); - } else if code.starts_with("E_CONFIG") { - config_errors.push(*code); - } else if code.starts_with("E_IO") { - io_errors.push(*code); - } else if code.starts_with("E_CREATE") { - create_errors.push(*code); - } else if code.starts_with("E_INTERNAL") { - internal_errors.push(*code); - } else if code.starts_with("W_SEM") { - semantic_warnings.push(*code); - } + for (name, group) in categorize(&codes) { + print_category(name, &group, theme); } - print_category("Parse Errors", &parse_errors, theme); - print_category("Lint Errors", &lint_errors, theme); - print_category("Semantic Warnings", &semantic_warnings, theme); - print_category("Dependency Errors", &dep_errors, theme); - print_category("Index Errors", &index_errors, theme); - print_category("Query Errors", &query_errors, theme); - print_category("Config Errors", &config_errors, theme); - print_category("IO Errors", &io_errors, theme); - print_category("Task Creation Errors", &create_errors, theme); - print_category("Internal Errors", &internal_errors, theme); - println!(); let total_msg = format!("Total: {} error codes available", codes.len()); if let Some(t) = theme { @@ -152,6 +109,68 @@ fn list_error_codes(args: &ExplainArgs, theme: Option<&CliTheme>) -> Result Ok(0) } +/// Categories for `--list`, in display order +/// +/// Each entry is a heading and the code prefixes that belong under it. The +/// first matching entry wins, so more specific prefixes must come before the +/// broader ones they would otherwise fall into (`E_INDEX_FILE_MISSING` is a +/// cross-file lint rule, not a database error). +const CATEGORIES: &[(&str, &[&str])] = &[ + ("Parse Errors", &["E_PARSE"]), + ("Lint Errors", &["E_LINT"]), + ("Syntax Rules", &["E_SYNTAX", "W_SYNTAX", "I_SYNTAX"]), + ( + "Semantic Rules", + &["E_SEM", "W_SEM", "I_SEM", "E_NOTE", "W_NOTE"], + ), + ( + "Cross-File Rules", + &["E_LINK", "E_INDEX_FILE_MISSING", "W_INDEX"], + ), + ("Dependency Errors", &["E_DEP"]), + ("Index Errors", &["E_INDEX"]), + ("Query Errors", &["E_QUERY"]), + ("Config Errors", &["E_CONFIG"]), + ("IO Errors", &["E_IO"]), + ("Task Creation Errors", &["E_CREATE"]), + ("Internal Errors", &["E_INTERNAL"]), +]; + +/// Heading for codes that match no category +/// +/// A new code should get a category, but landing here beats being silently +/// dropped from `--list` — which is how the linter's own codes went missing. +const UNCATEGORIZED: &str = "Other Codes"; + +/// Group codes into the display categories +/// +/// Returns one entry per non-empty category, in [`CATEGORIES`] order, with any +/// unmatched codes last under [`UNCATEGORIZED`]. +fn categorize<'a>(codes: &[&'a str]) -> Vec<(&'static str, Vec<&'a str>)> { + let mut groups: Vec<(&'static str, Vec<&'a str>)> = CATEGORIES + .iter() + .map(|(name, _)| (*name, Vec::new())) + .collect(); + let mut other = Vec::new(); + + for code in codes { + let category = CATEGORIES + .iter() + .position(|(_, prefixes)| prefixes.iter().any(|prefix| code.starts_with(prefix))); + + match category { + Some(index) => groups[index].1.push(code), + None => other.push(*code), + } + } + + groups.retain(|(_, group)| !group.is_empty()); + if !other.is_empty() { + groups.push((UNCATEGORIZED, other)); + } + groups +} + /// Print a category of error codes fn print_category(name: &str, codes: &[&str], theme: Option<&CliTheme>) { if codes.is_empty() { @@ -178,6 +197,21 @@ fn print_category(name: &str, codes: &[&str], theme: Option<&CliTheme>) { println!(); } +/// Heading label for a code, taken from its severity prefix +/// +/// `lash explain` now answers for warnings and info-level rules too, so calling +/// every one of them "Error:" would contradict the diagnostic the reader came +/// from. +fn severity_label(code: &str) -> &'static str { + if code.starts_with('W') { + "Warning:" + } else if code.starts_with('I') { + "Info:" + } else { + "Error:" + } +} + /// Print explanation in human-readable format fn print_human( explanation: &lash_types::error_explanations::ErrorExplanation, @@ -185,12 +219,15 @@ fn print_human( ) { println!(); + let label = severity_label(explanation.code); + if let Some(t) = theme { - println!( - "{} {}", - t.style_error("Error:"), - t.style_warning(explanation.code) - ); + let styled_label = match label { + "Warning:" => t.style_warning(label), + "Info:" => t.style_info(label), + _ => t.style_error(label), + }; + println!("{} {}", styled_label, t.style_warning(explanation.code)); println!(); println!("{}", t.style_label(explanation.summary)); println!(); @@ -228,7 +265,7 @@ fn print_human( println!(); } } else { - println!("Error: {}", explanation.code); + println!("{label} {}", explanation.code); println!(); println!("{}", explanation.summary); println!(); @@ -584,6 +621,126 @@ mod tests { assert_eq!(result, 0); } + // GitHub issue #58: codes that matched no prefix were dropped from --list + // without a trace. Every listed code must now appear under some heading. + #[test] + fn test_categorize_covers_every_listed_code() { + use lash_types::error_explanations::all_error_codes; + + let codes = all_error_codes(); + let grouped: Vec<&str> = categorize(&codes) + .into_iter() + .flat_map(|(_, group)| group) + .collect(); + + assert_eq!( + grouped.len(), + codes.len(), + "every code must land in exactly one category" + ); + for code in &codes { + assert!(grouped.contains(code), "{code} was dropped by categorize()"); + } + } + + // No code should currently need the fallback bucket. + #[test] + fn test_categorize_leaves_nothing_uncategorized() { + use lash_types::error_explanations::all_error_codes; + + let codes = all_error_codes(); + let uncategorized = categorize(&codes) + .into_iter() + .find(|(name, _)| *name == UNCATEGORIZED); + + assert!( + uncategorized.is_none(), + "unexpected uncategorized codes: {uncategorized:?}" + ); + } + + // E_INDEX_FILE_MISSING is a cross-file lint rule; the broader E_INDEX + // prefix (database errors) must not claim it first. + #[test] + fn test_categorize_routes_index_file_missing_to_cross_file_rules() { + let codes = vec!["E_INDEX_FILE_MISSING", "E_INDEX_CORRUPTED"]; + let grouped = categorize(&codes); + + let cross_file = grouped + .iter() + .find(|(name, _)| *name == "Cross-File Rules") + .expect("Cross-File Rules category expected"); + assert_eq!(cross_file.1, vec!["E_INDEX_FILE_MISSING"]); + + let index = grouped + .iter() + .find(|(name, _)| *name == "Index Errors") + .expect("Index Errors category expected"); + assert_eq!(index.1, vec!["E_INDEX_CORRUPTED"]); + } + + // Empty categories are omitted rather than printed with no codes. + #[test] + fn test_categorize_omits_empty_categories() { + let codes = vec!["E_PARSE_INVALID_CHECKBOX"]; + let grouped = categorize(&codes); + + assert_eq!(grouped.len(), 1); + assert_eq!(grouped[0].0, "Parse Errors"); + } + + // A code matching no prefix falls into the fallback bucket instead of + // vanishing from the listing. + #[test] + fn test_categorize_puts_unknown_prefix_in_fallback() { + let codes = vec!["X_BRAND_NEW_PREFIX"]; + let grouped = categorize(&codes); + + assert_eq!(grouped.len(), 1); + assert_eq!(grouped[0].0, UNCATEGORIZED); + assert_eq!(grouped[0].1, vec!["X_BRAND_NEW_PREFIX"]); + } + + #[test] + fn test_severity_label_follows_code_prefix() { + assert_eq!(severity_label("E_LINK_NOT_FOUND"), "Error:"); + assert_eq!(severity_label("W_INDEX_ORPHAN"), "Warning:"); + assert_eq!(severity_label("I_SEM_AUTO_WAIVE"), "Info:"); + } + + // A warning must not be introduced as "Error:" — it would contradict the + // diagnostic the reader came from. + #[test] + fn test_binary_explain_warning_code_is_labelled_warning() { + let Some(stdout) = run_lash(&["--no-color", "explain", "W_INDEX_ORPHAN"]) else { + return; + }; + assert!( + stdout.contains("Warning: W_INDEX_ORPHAN"), + "warning code must be labelled Warning:, got: {stdout}" + ); + } + + // The linter's own codes must be explainable — the lint output tells users + // to run `lash explain `. + #[test] + fn test_execute_with_linter_rule_codes() { + for code in [ + "W_INDEX_ORPHAN", + "E_LINK_NOT_FOUND", + "E_SYNTAX_CHECKBOX", + "E_SEM_DUPLICATE_ID", + ] { + let args = ExplainArgs { + code: code.to_string(), + list: false, + json: false, + no_color: true, + }; + assert_eq!(execute(&args).unwrap(), 0, "{code} must be explainable"); + } + } + // Kill mut-000274: print_category skips empty slices #[test] fn test_print_category_with_empty_codes_does_not_panic() { diff --git a/crates/lash-cli/src/commands/lint.rs b/crates/lash-cli/src/commands/lint.rs index 2deec5c..64edac3 100644 --- a/crates/lash-cli/src/commands/lint.rs +++ b/crates/lash-cli/src/commands/lint.rs @@ -546,6 +546,41 @@ fn print_summary( if !files_affected.is_empty() { println!(" {} files affected", files_affected.len()); } + + print_explain_hint(diagnostics, theme); +} + +/// Point at `lash explain` for the codes just reported +/// +/// The codes above are the only thing a reader has to go on, and every one of +/// them has an explanation (GitHub issue #58). Naming a code that is actually +/// on screen makes the next command obvious. +fn print_explain_hint(diagnostics: &[Diagnostic], theme: Option<&CliTheme>) { + let Some(hint) = explain_hint(diagnostics) else { + return; + }; + + if let Some(t) = theme { + println!(" {}", t.style_muted(&hint)); + } else { + println!(" {hint}"); + } +} + +/// Build the `lash explain` hint, using a reported code as the example +/// +/// Picks the first code `explain` actually knows: an example it would reject +/// sends the reader to the dead end this hint exists to prevent. Returns `None` +/// when nothing explainable was reported. +fn explain_hint(diagnostics: &[Diagnostic]) -> Option { + let example = diagnostics + .iter() + .map(|d| d.code) + .find(|code| lash_types::error_explanations::explain_error(code).is_some())?; + + Some(format!( + "Run 'lash explain {example}' for details on any code above." + )) } /// Load project configuration @@ -1810,6 +1845,78 @@ mod tests { print_summary(&[], 1, None, false); } + // GitHub issue #58: the codes in lint output are a dead end unless the + // reader knows `lash explain` takes them. + #[test] + fn test_explain_hint_names_a_reported_code() { + let diag = Diagnostic { + code: "W_INDEX_ORPHAN", + severity: Severity::Warning, + message: "orphan".to_string(), + location: None, + snippet: None, + help: None, + labels: None, + recovery_command: None, + fix_steps: None, + explanation: None, + docs_url: None, + }; + + let hint = explain_hint(&[diag]).expect("hint expected when a code was reported"); + assert!( + hint.contains("lash explain W_INDEX_ORPHAN"), + "hint must name a reported code, got: {hint}" + ); + } + + #[test] + fn test_explain_hint_absent_when_nothing_reported() { + assert!(explain_hint(&[]).is_none()); + } + + // Suggesting a code `lash explain` would reject is the dead end this hint + // exists to prevent, so an unexplainable code is skipped over. + #[test] + fn test_explain_hint_skips_codes_explain_does_not_know() { + let unknown = Diagnostic { + code: "X_NOT_A_REAL_CODE", + severity: Severity::Error, + message: "unknown".to_string(), + location: None, + snippet: None, + help: None, + labels: None, + recovery_command: None, + fix_steps: None, + explanation: None, + docs_url: None, + }; + let known = Diagnostic { + code: "E_LINK_NOT_FOUND", + severity: Severity::Error, + message: "broken link".to_string(), + location: None, + snippet: None, + help: None, + labels: None, + recovery_command: None, + fix_steps: None, + explanation: None, + docs_url: None, + }; + + let hint = explain_hint(&[unknown.clone(), known]).expect("hint expected"); + assert!( + hint.contains("lash explain E_LINK_NOT_FOUND"), + "hint must skip the unexplainable code, got: {hint}" + ); + assert!( + explain_hint(&[unknown]).is_none(), + "no hint when nothing reported is explainable" + ); + } + /// `print_summary` with only one Error diagnostic must not panic. /// This exercises `error_count` != 0 (skip the success messages) and `error_count` > 0 /// (styled `error_str` branch in themed mode — here without theme). diff --git a/crates/lash-cli/tests/snapshots/regression_tests__agent_prompt_output.snap b/crates/lash-cli/tests/snapshots/regression_tests__agent_prompt_output.snap index cff03a8..f71a4e1 100644 --- a/crates/lash-cli/tests/snapshots/regression_tests__agent_prompt_output.snap +++ b/crates/lash-cli/tests/snapshots/regression_tests__agent_prompt_output.snap @@ -291,7 +291,7 @@ lash skill install --target T # Install Lash skill into a coding agent (claude lash config # Manage configuration (get, set, list, path) # Error Help -lash explain # Explain error code (e.g., E001) +lash explain # Explain any code lint reports (e.g., W_INDEX_ORPHAN) lash explain --list # List all error codes ``` diff --git a/crates/lash-cli/tests/snapshots/regression_tests__error_invalid_status_stdout.snap b/crates/lash-cli/tests/snapshots/regression_tests__error_invalid_status_stdout.snap index 5c4bb2e..e3e5664 100644 --- a/crates/lash-cli/tests/snapshots/regression_tests__error_invalid_status_stdout.snap +++ b/crates/lash-cli/tests/snapshots/regression_tests__error_invalid_status_stdout.snap @@ -11,3 +11,4 @@ error[E_PARSE]: Failed to parse file: parse error: Invalid checkbox status '?': Summary: 1 errors, 0 warnings, 0 info, 0 hints 1 files affected + Run 'lash explain E_PARSE' for details on any code above. diff --git a/crates/lash-cli/tests/snapshots/regression_tests__error_missing_id_stdout.snap b/crates/lash-cli/tests/snapshots/regression_tests__error_missing_id_stdout.snap index 379e44b..4b9c0ca 100644 --- a/crates/lash-cli/tests/snapshots/regression_tests__error_missing_id_stdout.snap +++ b/crates/lash-cli/tests/snapshots/regression_tests__error_missing_id_stdout.snap @@ -5,10 +5,11 @@ expression: normalized_stdout ┓ ┓ ┃ ┏┓┏┣┓ ┗┛┗┻┛┛┗ -warning[W_INDEX_ORPHAN]: File './no-id.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './no-id.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./no-id.md:0:0 ✓ Linting passed (with warnings) Summary: 0 errors, 1 warnings, 0 info, 0 hints 1 files affected + Run 'lash explain W_INDEX_ORPHAN' for details on any code above. diff --git a/crates/lash-cli/tests/snapshots/regression_tests__lint_200_tasks_stdout.snap b/crates/lash-cli/tests/snapshots/regression_tests__lint_200_tasks_stdout.snap index 7f9f229..604b9f0 100644 --- a/crates/lash-cli/tests/snapshots/regression_tests__lint_200_tasks_stdout.snap +++ b/crates/lash-cli/tests/snapshots/regression_tests__lint_200_tasks_stdout.snap @@ -5,10 +5,11 @@ expression: normalized_stdout ┓ ┓ ┃ ┏┓┏┣┓ ┗┛┗┻┛┛┗ -warning[W_INDEX_ORPHAN]: File './many.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './many.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./many.md:0:0 ✓ Linting passed (with warnings) Summary: 0 errors, 1 warnings, 0 info, 0 hints 1 files affected + Run 'lash explain W_INDEX_ORPHAN' for details on any code above. diff --git a/crates/lash-cli/tests/snapshots/regression_tests__lint_deeply_nested_stdout.snap b/crates/lash-cli/tests/snapshots/regression_tests__lint_deeply_nested_stdout.snap index 272d824..c860785 100644 --- a/crates/lash-cli/tests/snapshots/regression_tests__lint_deeply_nested_stdout.snap +++ b/crates/lash-cli/tests/snapshots/regression_tests__lint_deeply_nested_stdout.snap @@ -5,24 +5,25 @@ expression: normalized_stdout ┓ ┓ ┃ ┏┓┏┣┓ ┗┛┗┻┛┛┗ -warning[W_INDEX_ORPHAN]: File './level1/core.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './level1/core.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./level1/core.md:0:0 -warning[W_INDEX_ORPHAN]: File './level1/level2/level3/api.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './level1/level2/level3/api.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./level1/level2/level3/api.md:0:0 -warning[W_INDEX_ORPHAN]: File './level1/level2/level3/level4/handlers.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './level1/level2/level3/level4/handlers.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./level1/level2/level3/level4/handlers.md:0:0 -warning[W_INDEX_ORPHAN]: File './level1/level2/level3/level4/level5/level6/level7/level8/utilities.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './level1/level2/level3/level4/level5/level6/level7/level8/utilities.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./level1/level2/level3/level4/level5/level6/level7/level8/utilities.md:0:0 -warning[W_INDEX_ORPHAN]: File './level1/level2/level3/level4/level5/level6/level7/transformers.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './level1/level2/level3/level4/level5/level6/level7/transformers.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./level1/level2/level3/level4/level5/level6/level7/transformers.md:0:0 -warning[W_INDEX_ORPHAN]: File './level1/level2/level3/level4/level5/level6/validators.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './level1/level2/level3/level4/level5/level6/validators.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./level1/level2/level3/level4/level5/level6/validators.md:0:0 -warning[W_INDEX_ORPHAN]: File './level1/level2/level3/level4/level5/middleware.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './level1/level2/level3/level4/level5/middleware.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./level1/level2/level3/level4/level5/middleware.md:0:0 -warning[W_INDEX_ORPHAN]: File './level1/level2/services.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './level1/level2/services.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./level1/level2/services.md:0:0 ✓ Linting passed (with warnings) Summary: 0 errors, 8 warnings, 0 info, 0 hints 8 files affected + Run 'lash explain W_INDEX_ORPHAN' for details on any code above. diff --git a/crates/lash-cli/tests/snapshots/regression_tests__lint_flat_project_stdout.snap b/crates/lash-cli/tests/snapshots/regression_tests__lint_flat_project_stdout.snap index 7d4990f..60d9a79 100644 --- a/crates/lash-cli/tests/snapshots/regression_tests__lint_flat_project_stdout.snap +++ b/crates/lash-cli/tests/snapshots/regression_tests__lint_flat_project_stdout.snap @@ -5,26 +5,27 @@ expression: normalized_stdout ┓ ┓ ┃ ┏┓┏┣┓ ┗┛┗┻┛┛┗ -warning[W_INDEX_ORPHAN]: File './api.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './api.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./api.md:0:0 -warning[W_INDEX_ORPHAN]: File './authentication.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './authentication.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./authentication.md:0:0 -warning[W_INDEX_ORPHAN]: File './database.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './database.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./database.md:0:0 -warning[W_INDEX_ORPHAN]: File './deployment.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './deployment.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./deployment.md:0:0 -warning[W_INDEX_ORPHAN]: File './documentation.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './documentation.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./documentation.md:0:0 -warning[W_INDEX_ORPHAN]: File './frontend.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './frontend.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./frontend.md:0:0 -warning[W_INDEX_ORPHAN]: File './monitoring.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './monitoring.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./monitoring.md:0:0 warning[W_SEM_STATUS_INCONSISTENT]: Parent task "Unit tests" is marked complete but has 1 incomplete child(ren) at ./testing.md:0:0 -warning[W_INDEX_ORPHAN]: File './testing.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './testing.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./testing.md:0:0 ✓ Linting passed (with warnings) Summary: 0 errors, 9 warnings, 0 info, 0 hints 8 files affected + Run 'lash explain W_INDEX_ORPHAN' for details on any code above. diff --git a/crates/lash-cli/tests/snapshots/regression_tests__lint_medium_project_stdout.snap b/crates/lash-cli/tests/snapshots/regression_tests__lint_medium_project_stdout.snap index 3a9eeb2..7de3323 100644 --- a/crates/lash-cli/tests/snapshots/regression_tests__lint_medium_project_stdout.snap +++ b/crates/lash-cli/tests/snapshots/regression_tests__lint_medium_project_stdout.snap @@ -5,20 +5,21 @@ expression: normalized_stdout ┓ ┓ ┃ ┏┓┏┣┓ ┗┛┗┻┛┛┗ -warning[W_INDEX_ORPHAN]: File './backend/api.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './backend/api.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./backend/api.md:0:0 -warning[W_INDEX_ORPHAN]: File './backend/database.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './backend/database.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./backend/database.md:0:0 -warning[W_INDEX_ORPHAN]: File './backend/tests.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './backend/tests.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./backend/tests.md:0:0 -warning[W_INDEX_ORPHAN]: File './frontend/components.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './frontend/components.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./frontend/components.md:0:0 -warning[W_INDEX_ORPHAN]: File './frontend/state.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './frontend/state.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./frontend/state.md:0:0 -warning[W_INDEX_ORPHAN]: File './frontend/tests.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './frontend/tests.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./frontend/tests.md:0:0 ✓ Linting passed (with warnings) Summary: 0 errors, 6 warnings, 0 info, 0 hints 6 files affected + Run 'lash explain W_INDEX_ORPHAN' for details on any code above. diff --git a/crates/lash-cli/tests/snapshots/regression_tests__lint_mixed_structure_stdout.snap b/crates/lash-cli/tests/snapshots/regression_tests__lint_mixed_structure_stdout.snap index d6fc815..d1c99c0 100644 --- a/crates/lash-cli/tests/snapshots/regression_tests__lint_mixed_structure_stdout.snap +++ b/crates/lash-cli/tests/snapshots/regression_tests__lint_mixed_structure_stdout.snap @@ -5,20 +5,21 @@ expression: normalized_stdout ┓ ┓ ┃ ┏┓┏┣┓ ┗┛┗┻┛┛┗ -warning[W_INDEX_ORPHAN]: File './nested/nested-task-1.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './nested/nested-task-1.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./nested/nested-task-1.md:0:0 -warning[W_INDEX_ORPHAN]: File './nested/sub1/deep-task-1.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './nested/sub1/deep-task-1.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./nested/sub1/deep-task-1.md:0:0 -warning[W_INDEX_ORPHAN]: File './nested/sub1/sub2/deepest-task.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './nested/sub1/sub2/deepest-task.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./nested/sub1/sub2/deepest-task.md:0:0 -warning[W_INDEX_ORPHAN]: File './root-task-1.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './root-task-1.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./root-task-1.md:0:0 -warning[W_INDEX_ORPHAN]: File './root-task-2.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './root-task-2.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./root-task-2.md:0:0 -warning[W_INDEX_ORPHAN]: File './root-task-3.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './root-task-3.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./root-task-3.md:0:0 ✓ Linting passed (with warnings) Summary: 0 errors, 6 warnings, 0 info, 0 hints 6 files affected + Run 'lash explain W_INDEX_ORPHAN' for details on any code above. diff --git a/crates/lash-cli/tests/snapshots/regression_tests__lint_small_project_stdout.snap b/crates/lash-cli/tests/snapshots/regression_tests__lint_small_project_stdout.snap index c476cdc..408acc1 100644 --- a/crates/lash-cli/tests/snapshots/regression_tests__lint_small_project_stdout.snap +++ b/crates/lash-cli/tests/snapshots/regression_tests__lint_small_project_stdout.snap @@ -5,12 +5,13 @@ expression: normalized_stdout ┓ ┓ ┃ ┏┓┏┣┓ ┗┛┗┻┛┛┗ -warning[W_INDEX_ORPHAN]: File './bugs.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './bugs.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./bugs.md:0:0 -warning[W_INDEX_ORPHAN]: File './tasks.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './tasks.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./tasks.md:0:0 ✓ Linting passed (with warnings) Summary: 0 errors, 2 warnings, 0 info, 0 hints 2 files affected + Run 'lash explain W_INDEX_ORPHAN' for details on any code above. diff --git a/crates/lash-cli/tests/snapshots/regression_tests__lint_unicode_filename_stdout.snap b/crates/lash-cli/tests/snapshots/regression_tests__lint_unicode_filename_stdout.snap index 19f9a50..9879037 100644 --- a/crates/lash-cli/tests/snapshots/regression_tests__lint_unicode_filename_stdout.snap +++ b/crates/lash-cli/tests/snapshots/regression_tests__lint_unicode_filename_stdout.snap @@ -5,10 +5,11 @@ expression: normalized_stdout ┓ ┓ ┃ ┏┓┏┣┓ ┗┛┗┻┛┛┗ -warning[W_INDEX_ORPHAN]: File './日本語.md' is not referenced in the root index +warning[W_INDEX_ORPHAN]: File './日本語.md' is not referenced in the root index (add it to .lashignore if it is not a task file) at ./日本語.md:0:0 ✓ Linting passed (with warnings) Summary: 0 errors, 1 warnings, 0 info, 0 hints 1 files affected + Run 'lash explain W_INDEX_ORPHAN' for details on any code above. diff --git a/crates/lash-core/src/linter/registry.rs b/crates/lash-core/src/linter/registry.rs index 9ab7103..4d56206 100644 --- a/crates/lash-core/src/linter/registry.rs +++ b/crates/lash-core/src/linter/registry.rs @@ -412,6 +412,37 @@ mod tests { assert_eq!(registry.category_count(RuleCategory::CrossFile), 5); } + // GitHub issue #58: lint diagnostics tell users to run `lash explain + // `, so every rule's code must have an explanation. A new rule + // without one sends the reader to a dead end. + #[test] + fn test_every_default_rule_code_is_explainable() { + use lash_types::error_explanations::explain_error; + + for rule in register_default_rules(None).all_rules() { + let code = rule.code(); + assert!( + explain_error(code).is_some(), + "rule {code} has no entry in lash_types::error_explanations — \ + add one so `lash explain {code}` works" + ); + } + } + + // The severity-dependent codes are emitted by rules whose code() reports + // only the warning-level variant, so they need checking separately. + #[test] + fn test_severity_variant_codes_are_explainable() { + use lash_types::error_explanations::explain_error; + + for code in ["E_SEM_DESC_TOO_LONG", "E_NOTE_EXCESSIVE_LENGTH"] { + assert!( + explain_error(code).is_some(), + "{code} is emitted by a rule but has no explanation" + ); + } + } + #[test] fn test_registry_with_custom_description_length() { let config = LintConfig { diff --git a/crates/lash-core/src/linter/rules/crossfile/orphaned_files.rs b/crates/lash-core/src/linter/rules/crossfile/orphaned_files.rs index 65905f6..f198626 100644 --- a/crates/lash-core/src/linter/rules/crossfile/orphaned_files.rs +++ b/crates/lash-core/src/linter/rules/crossfile/orphaned_files.rs @@ -15,6 +15,11 @@ use crate::linter::{LintContext, LintDiagnostic, LintRule}; /// This rule checks that all task files in the project are referenced in the root /// index. Files not in the index are considered "orphaned" and generate a warning. /// +/// Common documentation filenames and documentation directories are exempt (see +/// [`OrphanedFilesRule::should_skip_orphan_check`]). Anything else that is not a +/// task file — prose, generated content — belongs in a `.lashignore` at the +/// project root, which removes it from file discovery entirely. +/// /// # Examples /// /// Valid (no orphaned files): @@ -258,15 +263,22 @@ impl LintRule for OrphanedFilesRule { LintDiagnostic::warning( self.code(), format!( - "File '{}' is not referenced in the root index", + "File '{}' is not referenced in the root index \ + (add it to .lashignore if it is not a task file)", file.path.display() ), file.path.clone(), 0, 0, ) + // The per-diagnostic help only surfaces under `-v`, which is + // why the .lashignore pointer is in the message too: a + // directory of non-task Markdown produces one of these per + // file, and the escape hatch has to be visible from the + // warning itself (GitHub issue #58). .with_help(format!( - "Add '{}' to {} or move to an archive directory", + "Add '{}' to {}, list it in .lashignore if it is not a task file, \ + or move it to an archive directory", file.path.display(), index_file.path.display() )), @@ -391,6 +403,45 @@ mod tests { assert!(diagnostics[0].message.contains("orphan.md")); } + // GitHub issue #58: a directory of non-task Markdown produces one of these + // per file, and `.lashignore` — the fix — was reachable from no surface a + // user looks at. The message itself carries the pointer because help text + // only shows under `-v`. + #[test] + fn test_orphan_message_points_at_lashignore() { + let rule = OrphanedFilesRule::new(); + let config = LashConfig::default(); + + let mut files = HashMap::new(); + files.insert( + PathBuf::from("lash.index.md"), + make_index_file("lash.index.md", &["tasks.md"]), + ); + files.insert( + PathBuf::from("content/a-post.md"), + make_regular_file("content/a-post.md", "a-post"), + ); + + let ctx = LintContext::new(&config, PathBuf::from("content/a-post.md"), &files); + let orphan_file = files.get(&PathBuf::from("content/a-post.md")).unwrap(); + + let diagnostics = rule.check_file(orphan_file, &ctx); + assert_eq!(diagnostics.len(), 1); + assert!( + diagnostics[0].message.contains(".lashignore"), + "message must name .lashignore, got: {}", + diagnostics[0].message + ); + assert!( + diagnostics[0] + .help + .as_ref() + .is_some_and(|help| help.contains(".lashignore")), + "help must name .lashignore, got: {:?}", + diagnostics[0].help + ); + } + #[test] fn test_index_file_not_checked() { let rule = OrphanedFilesRule::new(); diff --git a/crates/lash-types/src/error.rs b/crates/lash-types/src/error.rs index 7d00ad4..5b3e30f 100644 --- a/crates/lash-types/src/error.rs +++ b/crates/lash-types/src/error.rs @@ -1132,6 +1132,8 @@ impl fmt::Display for Severity { /// - Agent integration pub mod codes { // Parse errors (E_PARSE_*) + /// A file could not be parsed; the message carries the specific reason + pub const E_PARSE: &str = "E_PARSE"; /// Invalid checkbox syntax pub const E_PARSE_INVALID_CHECKBOX: &str = "E_PARSE_INVALID_CHECKBOX"; /// Malformed annotation @@ -1158,8 +1160,75 @@ pub mod codes { pub const E_LINT_BAD_INDENTATION: &str = "E_LINT_BAD_INDENTATION"; /// Invalid label format pub const E_LINT_INVALID_LABEL: &str = "E_LINT_INVALID_LABEL"; + // Linter rule codes — the codes `lash lint` actually emits. + // + // Syntax rules (E_SYNTAX_*, W_SYNTAX_*, I_SYNTAX_*) + /// Checkbox marker is not one of `[ ]`, `[x]`, `[-]`, `[!]` + pub const E_SYNTAX_CHECKBOX: &str = "E_SYNTAX_CHECKBOX"; + /// Checkbox indentation is not a multiple of 2 spaces + pub const E_SYNTAX_INDENT: &str = "E_SYNTAX_INDENT"; + /// Task nesting exceeds the configured depth limit + pub const E_SYNTAX_DEPTH: &str = "E_SYNTAX_DEPTH"; + /// Annotation line does not match `@key: value` + pub const E_SYNTAX_ANNOTATION: &str = "E_SYNTAX_ANNOTATION"; + /// Annotation key is neither built-in nor explicitly allowed + pub const E_SYNTAX_UNKNOWN_KEY: &str = "E_SYNTAX_UNKNOWN_KEY"; + /// File has more than one `## Description` section + pub const E_SYNTAX_DUPLICATE_DESCRIPTION: &str = "E_SYNTAX_DUPLICATE_DESCRIPTION"; + /// File is missing its H1 title or `## Tasks` section + pub const W_SYNTAX_HEADER: &str = "W_SYNTAX_HEADER"; + /// Annotations are not in the conventional order + pub const I_SYNTAX_ORDER: &str = "I_SYNTAX_ORDER"; + + // Semantic rules (E_SEM_*, W_SEM_*, I_SEM_*) + /// Two tasks in the same file share an ID + pub const E_SEM_DUPLICATE_ID: &str = "E_SEM_DUPLICATE_ID"; + /// Task has an empty title + pub const E_SEM_EMPTY_TITLE: &str = "E_SEM_EMPTY_TITLE"; + /// Date annotation is not a valid `YYYY-MM-DD` date + pub const E_SEM_INVALID_DATE: &str = "E_SEM_INVALID_DATE"; + /// `@doc:` annotation points at a file that does not exist + pub const E_SEM_INVALID_DOC: &str = "E_SEM_INVALID_DOC"; + /// `@estimate:` value is not a number followed by h/d/w/m/y + pub const E_SEM_INVALID_ESTIMATE: &str = "E_SEM_INVALID_ESTIMATE"; + /// Label is not lowercase alphanumeric with hyphens/underscores + pub const E_SEM_INVALID_LABEL: &str = "E_SEM_INVALID_LABEL"; + /// Description exceeds the hard length limit + pub const E_SEM_DESC_TOO_LONG: &str = "E_SEM_DESC_TOO_LONG"; + /// Description exceeds the recommended length + pub const W_SEM_DESC_TOO_LONG: &str = "W_SEM_DESC_TOO_LONG"; /// `@doc:` fragment does not match any heading in the target document pub const W_SEM_DOC_FRAGMENT: &str = "W_SEM_DOC_FRAGMENT"; + /// `@owner:` value is empty or implausibly long + pub const W_SEM_OWNER_FORMAT: &str = "W_SEM_OWNER_FORMAT"; + /// Parent is marked done while a child is still open + pub const W_SEM_STATUS_INCONSISTENT: &str = "W_SEM_STATUS_INCONSISTENT"; + /// Children of a waived parent can be auto-waived + pub const I_SEM_AUTO_WAIVE: &str = "I_SEM_AUTO_WAIVE"; + + // Contextual note rules (E_NOTE_*, W_NOTE_*) + /// Contextual note is not indented 2 spaces past its task + pub const E_NOTE_INVALID_INDENT: &str = "E_NOTE_INVALID_INDENT"; + /// Contextual note has nested children + pub const E_NOTE_HAS_CHILDREN: &str = "E_NOTE_HAS_CHILDREN"; + /// Contextual note exceeds the hard length limit + pub const E_NOTE_EXCESSIVE_LENGTH: &str = "E_NOTE_EXCESSIVE_LENGTH"; + /// Contextual note exceeds the recommended length + pub const W_NOTE_TOO_LONG: &str = "W_NOTE_TOO_LONG"; + /// Contextual note appears after child tasks + pub const W_NOTE_AFTER_CHILD_TASKS: &str = "W_NOTE_AFTER_CHILD_TASKS"; + + // Cross-file rules (E_LINK_*, E_INDEX_FILE_MISSING, W_INDEX_ORPHAN) + /// `@depends-on:` target file or task does not exist + pub const E_LINK_NOT_FOUND: &str = "E_LINK_NOT_FOUND"; + /// Dependency references form a cycle + pub const E_LINK_CYCLE: &str = "E_LINK_CYCLE"; + /// Dependency path is malformed or escapes the project root + pub const E_LINK_INVALID_PATH: &str = "E_LINK_INVALID_PATH"; + /// Root index references a file that does not exist + pub const E_INDEX_FILE_MISSING: &str = "E_INDEX_FILE_MISSING"; + /// Markdown file is not referenced in the root index + pub const W_INDEX_ORPHAN: &str = "W_INDEX_ORPHAN"; // Dependency errors (E_DEP_*) /// Dependency target not found diff --git a/crates/lash-types/src/error_explanations.rs b/crates/lash-types/src/error_explanations.rs deleted file mode 100644 index 910450b..0000000 --- a/crates/lash-types/src/error_explanations.rs +++ /dev/null @@ -1,661 +0,0 @@ -//! Detailed error explanations for the `lash explain` command -//! -//! This module provides comprehensive documentation for each error code, -//! including: -//! - What the error means -//! - Why it occurs -//! - How to fix it -//! - Examples of the error and correct code - -use crate::error::codes; - -/// Detailed explanation of an error code -#[derive(Debug, Clone)] -pub struct ErrorExplanation { - /// The error code being explained - pub code: &'static str, - - /// One-line summary of the error - pub summary: &'static str, - - /// Detailed description of what causes this error - pub description: &'static str, - - /// Why this error matters (what could go wrong if not fixed) - pub why_it_matters: &'static str, - - /// How to fix the error - pub how_to_fix: &'static str, - - /// Example of code that would produce this error - pub example_bad: Option<&'static str>, - - /// Example of correct code - pub example_good: Option<&'static str>, -} - -impl ErrorExplanation { - /// Format the explanation as markdown text - #[must_use] - pub fn to_markdown(&self) -> String { - let mut output = String::new(); - - output.push_str(&format!("# Error: {}\n\n", self.code)); - output.push_str(&format!("## {}\n\n", self.summary)); - output.push_str(&format!("**Description:** {}\n\n", self.description)); - output.push_str(&format!("**Why it matters:** {}\n\n", self.why_it_matters)); - output.push_str(&format!("**How to fix:** {}\n\n", self.how_to_fix)); - - if let Some(bad) = self.example_bad { - output.push_str("### Example (Incorrect)\n\n"); - output.push_str("```markdown\n"); - output.push_str(bad); - output.push_str("\n```\n\n"); - } - - if let Some(good) = self.example_good { - output.push_str("### Example (Correct)\n\n"); - output.push_str("```markdown\n"); - output.push_str(good); - output.push_str("\n```\n\n"); - } - - output - } -} - -/// Get the explanation for a specific error code -/// -/// Returns `None` if the error code is not recognized. -#[must_use] -pub fn explain_error(code: &str) -> Option { - match code { - // ===== Parse Errors ===== - codes::E_PARSE_INVALID_CHECKBOX => Some(ErrorExplanation { - code: codes::E_PARSE_INVALID_CHECKBOX, - summary: "Invalid checkbox syntax", - description: "The checkbox marker in your task is not valid. Lash only recognizes four checkbox states: [ ] for open, [x] for done, [-] for waived, and [!] for blocked.", - why_it_matters: "Invalid checkboxes prevent Lash from parsing your task files correctly, which means the task won't be indexed, tracked, or included in dependency resolution.", - how_to_fix: "Replace the invalid checkbox with one of the valid formats: [ ], [x], [-], or [!]. Run `lash format` to automatically fix checkbox formatting.", - example_bad: Some("- [*] Invalid checkbox\n- [v] Also invalid\n- [] Missing space"), - example_good: Some("- [ ] Open task\n- [x] Completed task\n- [-] Waived task\n- [!] Blocked task"), - }), - - codes::E_PARSE_INVALID_ANNOTATION => Some(ErrorExplanation { - code: codes::E_PARSE_INVALID_ANNOTATION, - summary: "Malformed annotation", - description: "An annotation in your task file doesn't follow the required @key: value format. Annotations must start with @, followed by the annotation name, a colon, and the value.", - why_it_matters: "Malformed annotations can't be parsed, so Lash won't be able to read metadata like task IDs, labels, owners, or dependencies.", - how_to_fix: "Ensure annotations follow the format: @key: value. There must be a space after the colon. Run `lash format` to normalize formatting.", - example_bad: Some("@id task-1\n@labels: frontend, backend\n@owner:Alice"), - example_good: Some("@id: task-1\n@labels: frontend, backend\n@owner: Alice"), - }), - - codes::E_PARSE_INVALID_HEADER => Some(ErrorExplanation { - code: codes::E_PARSE_INVALID_HEADER, - summary: "Invalid header format", - description: "A header in your file doesn't follow proper Markdown syntax. Headers must start with one or more # symbols followed by a space.", - why_it_matters: "Invalid headers break the document structure, making it impossible for Lash to organize tasks into sections.", - how_to_fix: "Add a space after the # symbols in your headers. For example, change '##Tasks' to '## Tasks'.", - example_bad: Some("##Tasks\n###Section 1"), - example_good: Some("## Tasks\n### Section 1"), - }), - - codes::E_PARSE_UNEXPECTED_DEPTH => Some(ErrorExplanation { - code: codes::E_PARSE_UNEXPECTED_DEPTH, - summary: "Unexpected indentation depth", - description: "A task or subtask has incorrect indentation. Each level of nesting should be indented by exactly 2 spaces.", - why_it_matters: "Incorrect indentation breaks the task hierarchy, which affects dependency resolution and task organization.", - how_to_fix: "Adjust the indentation to use exactly 2 spaces per nesting level. Run `lash format` to automatically fix indentation.", - example_bad: Some("- [ ] Parent\n - [ ] Child (3 spaces)\n- [ ] Another (wrong depth)"), - example_good: Some("- [ ] Parent\n - [ ] Child (2 spaces)\n - [ ] Grandchild (4 spaces)"), - }), - - codes::E_PARSE_INVALID_DATE => Some(ErrorExplanation { - code: codes::E_PARSE_INVALID_DATE, - summary: "Invalid date format", - description: "A date annotation doesn't use the required YYYY-MM-DD format.", - why_it_matters: "Invalid dates can't be parsed or compared, breaking features like task filtering by date and timeline calculations.", - how_to_fix: "Change the date to YYYY-MM-DD format. For example, '2024-01-15' for January 15, 2024.", - example_bad: Some("@created: 01/15/2024\n@created: Jan 15, 2024"), - example_good: Some("@created: 2024-01-15"), - }), - - // ===== Lint Errors ===== - codes::E_LINT_DUPLICATE_ID => Some(ErrorExplanation { - code: codes::E_LINT_DUPLICATE_ID, - summary: "Duplicate task ID", - description: "Two or more tasks in the same file have the same @id annotation. Task IDs must be unique within a file.", - why_it_matters: "Duplicate IDs make it impossible to reference specific tasks unambiguously, breaking dependency links and task lookups.", - how_to_fix: "Rename one of the duplicate IDs to a unique value. Choose descriptive, unique identifiers for each task.", - example_bad: Some("- [ ] First task\n @id: setup\n\n- [ ] Second task\n @id: setup"), - example_good: Some("- [ ] First task\n @id: setup-database\n\n- [ ] Second task\n @id: setup-server"), - }), - - codes::E_LINT_UNKNOWN_ANNOTATION => Some(ErrorExplanation { - code: codes::E_LINT_UNKNOWN_ANNOTATION, - summary: "Unknown annotation", - description: "The annotation used is not recognized by Lash. Valid annotations are: @id, @labels, @owner, @estimate, @depends-on, @created, @doc, and @agent-note.", - why_it_matters: "Unknown annotations are ignored by Lash, which may indicate a typo or misunderstanding of the annotation system.", - how_to_fix: "Check the annotation name for typos, or remove it if it's not needed. Refer to the documentation for the list of valid annotations.", - example_bad: Some("@task-id: my-task\n@priority: high"), - example_good: Some("@id: my-task\n@labels: priority-high"), - }), - - codes::E_LINT_DEPTH_EXCEEDED => Some(ErrorExplanation { - code: codes::E_LINT_DEPTH_EXCEEDED, - summary: "Task nesting exceeds maximum depth", - description: "Tasks are nested too deeply. The maximum recommended depth is 4 levels (parent, child, grandchild, great-grandchild).", - why_it_matters: "Excessive nesting makes task files hard to read and maintain. It often indicates that tasks should be broken into separate files.", - how_to_fix: "Flatten the task hierarchy by moving deeply nested tasks to a separate file or restructuring the task breakdown.", - example_bad: Some("- [ ] Level 1\n - [ ] Level 2\n - [ ] Level 3\n - [ ] Level 4\n - [ ] Level 5 (too deep)"), - example_good: Some("- [ ] High-level task\n @depends-on: detailed-tasks.md#task:setup\n\n(Move details to detailed-tasks.md)"), - }), - - codes::E_LINT_STATUS_INCONSISTENCY => Some(ErrorExplanation { - code: codes::E_LINT_STATUS_INCONSISTENCY, - summary: "Parent task marked done with incomplete children", - description: "A parent task is marked as done [x] but has child tasks that are still open [ ].", - why_it_matters: "This creates logical inconsistency in your task hierarchy. A parent can only be complete when all its children are complete or waived.", - how_to_fix: "Either mark all child tasks as done/waived, or change the parent status to open or blocked.", - example_bad: Some("- [x] Complete feature\n - [ ] Write code\n - [ ] Write tests"), - example_good: Some("- [ ] Complete feature\n - [x] Write code\n - [x] Write tests\n\nOR\n\n- [x] Complete feature\n - [x] Write code\n - [x] Write tests"), - }), - - codes::E_LINT_INVALID_LABEL => Some(ErrorExplanation { - code: codes::E_LINT_INVALID_LABEL, - summary: "Invalid label format", - description: "A label contains invalid characters. Labels must be alphanumeric with hyphens, and multiple labels should be comma-separated.", - why_it_matters: "Invalid labels can't be used for filtering and may cause parsing errors.", - how_to_fix: "Use only letters, numbers, and hyphens in labels. Separate multiple labels with commas.", - example_bad: Some("@labels: front-end!, back_end\n@labels: high priority"), - example_good: Some("@labels: front-end, back-end\n@labels: high-priority"), - }), - - codes::E_LINT_MISSING_ANNOTATION => Some(ErrorExplanation { - code: codes::E_LINT_MISSING_ANNOTATION, - summary: "Missing required annotation", - description: "A task is missing a required annotation, typically @id. Some projects require certain annotations for proper tracking.", - why_it_matters: "Missing required annotations prevent proper task identification and cross-referencing.", - how_to_fix: "Add the required annotation to the task. For @id, choose a unique, descriptive identifier.", - example_bad: Some("- [ ] My task without ID"), - example_good: Some("- [ ] My task\n @id: my-task-id"), - }), - - codes::E_LINT_BAD_INDENTATION => Some(ErrorExplanation { - code: codes::E_LINT_BAD_INDENTATION, - summary: "Incorrect indentation", - description: "The indentation doesn't match the expected 2-space increments for task nesting.", - why_it_matters: "Inconsistent indentation breaks the task hierarchy and makes files hard to read.", - how_to_fix: "Run `lash format` to automatically fix indentation to the standard 2-space increments.", - example_bad: Some("- [ ] Parent\n - [ ] Child (3 spaces)\n - [ ] Another (4 spaces)"), - example_good: Some("- [ ] Parent\n - [ ] Child (2 spaces)\n - [ ] Another (2 spaces)"), - }), - - codes::W_SEM_DOC_FRAGMENT => Some(ErrorExplanation { - code: codes::W_SEM_DOC_FRAGMENT, - summary: "@doc: fragment does not match any heading", - description: "An @doc annotation references a #fragment that does not exist in the target document. Lash matches fragments against headings using case- and punctuation-insensitive normalization: both the fragment and each heading are lowercased, '-' is treated as whitespace, every non-alphanumeric/non-whitespace character (including '<', '>', '/', '.', '_', '(', ')', and backticks) is stripped *without* introducing a hyphen boundary, and runs of whitespace are collapsed. Two strings match when they reduce to the same canonical form.", - why_it_matters: "Broken @doc: fragments mean readers (humans and agents) following the link cannot land on the intended section. The lint catches them so they fail loudly instead of silently 404-ing in a renderer that ignores anchors.", - how_to_fix: "Open the target document, find the heading you want, and write the fragment so it normalizes to the same canonical form. The warning's help text lists existing headings in the target — pick one. Convention: lowercase the heading, replace spaces with '-', and drop punctuation entirely (do not turn '/' or '.' into a hyphen).", - example_bad: Some("# Heading: Pack manifest (`/SKILL.md`)\n@doc: ../docs/skills.md#pack-manifest-pack-skill-md\n# (`<` `>` `/` are stripped without producing a boundary, so this slug is wrong)"), - example_good: Some("# Heading: Pack manifest (`/SKILL.md`)\n@doc: ../docs/skills.md#pack-manifest-packskillmd\n\n# Heading: Validation rules (must pass at index time)\n@doc: ../docs/skills.md#validation-rules-must-pass-at-index-time"), - }), - - // ===== Dependency Errors ===== - codes::E_DEP_NOT_FOUND => Some(ErrorExplanation { - code: codes::E_DEP_NOT_FOUND, - summary: "Dependency target not found", - description: "A task references a dependency that doesn't exist. The referenced file or task ID could not be found.", - why_it_matters: "Broken dependencies prevent accurate dependency graph construction and can cause tasks to appear blocked incorrectly.", - how_to_fix: "Check that the file path and task ID in the @depends-on annotation are correct. Verify the referenced task exists and has the correct @id.", - example_bad: Some("@depends-on: path/to/missing.md#task:nonexistent-id"), - example_good: Some("@depends-on: path/to/existing.md#task:valid-id"), - }), - - codes::E_DEP_CYCLE => Some(ErrorExplanation { - code: codes::E_DEP_CYCLE, - summary: "Circular dependency detected", - description: "A cycle exists in the dependency graph where task A depends on B, which depends on C, which depends back on A.", - why_it_matters: "Circular dependencies create logical impossibilities and prevent proper task ordering. No task in the cycle can ever be started.", - how_to_fix: "Break the cycle by removing one of the dependencies or restructuring the task relationships. The error message shows the cycle path.", - example_bad: Some("Task A @depends-on: B\nTask B @depends-on: C\nTask C @depends-on: A"), - example_good: Some("Task A @depends-on: B\nTask B @depends-on: C\nTask C has no dependencies"), - }), - - codes::E_DEP_INVALID_REF => Some(ErrorExplanation { - code: codes::E_DEP_INVALID_REF, - summary: "Invalid dependency reference format", - description: "The @depends-on annotation doesn't follow the required format: path/to/file.md#task:id", - why_it_matters: "Invalid reference format prevents Lash from resolving dependencies correctly.", - how_to_fix: "Use the format: path/to/file.md#task:id where the path is relative to the project root.", - example_bad: Some("@depends-on: file.md#id\n@depends-on: just-an-id"), - example_good: Some("@depends-on: tasks/setup.md#task:database-setup"), - }), - - // ===== Index Errors ===== - codes::E_INDEX_CORRUPTED => Some(ErrorExplanation { - code: codes::E_INDEX_CORRUPTED, - summary: "Database corruption detected", - description: "The SQLite database has become corrupted or contains invalid data.", - why_it_matters: "A corrupted index prevents Lash from functioning correctly and may lead to data loss or incorrect results.", - how_to_fix: "Run `lash index --rebuild` to rebuild the database from scratch from your Markdown files.", - example_bad: None, - example_good: None, - }), - - codes::E_INDEX_VERSION_MISMATCH => Some(ErrorExplanation { - code: codes::E_INDEX_VERSION_MISMATCH, - summary: "Database schema version mismatch", - description: "The database was created with a different version of Lash and needs to be migrated to the current schema.", - why_it_matters: "Version mismatches can cause incorrect behavior or crashes when Lash tries to read incompatible database structures.", - how_to_fix: "Run `lash index --migrate` to update the database schema, or `lash index --rebuild` to rebuild from scratch.", - example_bad: None, - example_good: None, - }), - - codes::E_INDEX_OUT_OF_SYNC => Some(ErrorExplanation { - code: codes::E_INDEX_OUT_OF_SYNC, - summary: "Index is out of sync with files", - description: "The SQLite index doesn't match the current state of your Markdown files. Files have been modified since the last index update.", - why_it_matters: "An out-of-sync index means queries may return stale or incorrect data.", - how_to_fix: "Run `lash index` to update the index. Lash automatically detects changed files and updates only what's necessary.", - example_bad: None, - example_good: None, - }), - - // ===== Query Errors ===== - codes::E_QUERY_INVALID_SYNTAX => Some(ErrorExplanation { - code: codes::E_QUERY_INVALID_SYNTAX, - summary: "Invalid query syntax", - description: "The search query uses invalid syntax that can't be parsed.", - why_it_matters: "Invalid query syntax prevents the search from executing.", - how_to_fix: "Check the query syntax. Run `lash help search` for documentation on search query syntax.", - example_bad: None, - example_good: None, - }), - - codes::E_QUERY_NO_RESULTS => Some(ErrorExplanation { - code: codes::E_QUERY_NO_RESULTS, - summary: "No results found", - description: "The query executed successfully but returned no matching tasks.", - why_it_matters: "This may indicate the search criteria are too restrictive or the expected tasks don't exist.", - how_to_fix: "Try broadening your search criteria, removing some filters, or checking that the tasks you're looking for actually exist.", - example_bad: None, - example_good: None, - }), - - // ===== Config Errors ===== - codes::E_CONFIG_ROOT_NOT_FOUND => Some(ErrorExplanation { - code: codes::E_CONFIG_ROOT_NOT_FOUND, - summary: "Project root not found", - description: "Lash couldn't find a project root directory. It looks for lash.index.md, index.lash.md, or a .lash/ directory.", - why_it_matters: "Without a project root, Lash doesn't know where to look for task files or store the database.", - how_to_fix: "Run `lash init` to create a new project, or navigate to an existing Lash project directory. You can also use --root to specify the project root explicitly.", - example_bad: None, - example_good: None, - }), - - codes::E_CONFIG_INVALID_VALUE => Some(ErrorExplanation { - code: codes::E_CONFIG_INVALID_VALUE, - summary: "Invalid configuration value", - description: "A configuration value is invalid or out of acceptable range.", - why_it_matters: "Invalid configuration prevents Lash from starting or causes incorrect behavior.", - how_to_fix: "Check the configuration file for the invalid value and correct it according to the documentation.", - example_bad: None, - example_good: None, - }), - - codes::E_CONFIG_PARSE_ERROR => Some(ErrorExplanation { - code: codes::E_CONFIG_PARSE_ERROR, - summary: "Configuration parse error", - description: "The configuration file couldn't be parsed as valid TOML.", - why_it_matters: "A malformed configuration file prevents Lash from loading user preferences.", - how_to_fix: "Check the configuration file for syntax errors. Ensure it's valid TOML format.", - example_bad: None, - example_good: None, - }), - - codes::E_CONFIG_MISSING_INDEX => Some(ErrorExplanation { - code: codes::E_CONFIG_MISSING_INDEX, - summary: "Index file not found", - description: "The project root exists but doesn't contain an index file (lash.index.md or index.lash.md).", - why_it_matters: "The index file is the entry point for task navigation and defines the project structure.", - how_to_fix: "Create an index file at the project root: either lash.index.md or index.lash.md.", - example_bad: None, - example_good: None, - }), - - // ===== IO Errors ===== - codes::E_IO_FILE_NOT_FOUND => Some(ErrorExplanation { - code: codes::E_IO_FILE_NOT_FOUND, - summary: "File not found", - description: "The specified file doesn't exist.", - why_it_matters: "Lash can't operate on files that don't exist.", - how_to_fix: "Check that the file path is correct. If the file was moved or deleted, update any references to it.", - example_bad: None, - example_good: None, - }), - - codes::E_IO_READ_ERROR => Some(ErrorExplanation { - code: codes::E_IO_READ_ERROR, - summary: "Failed to read file", - description: "An I/O error occurred while trying to read a file.", - why_it_matters: "If Lash can't read files, it can't parse tasks or update the index.", - how_to_fix: "Check file permissions, disk space, and that the file isn't locked by another process.", - example_bad: None, - example_good: None, - }), - - codes::E_IO_WRITE_ERROR => Some(ErrorExplanation { - code: codes::E_IO_WRITE_ERROR, - summary: "Failed to write file", - description: "An I/O error occurred while trying to write to a file.", - why_it_matters: "Write errors prevent Lash from saving changes, formatting files, or creating new files.", - how_to_fix: "Check that you have write permissions, sufficient disk space, and that the file isn't read-only.", - example_bad: None, - example_good: None, - }), - - codes::E_IO_PERMISSION_DENIED => Some(ErrorExplanation { - code: codes::E_IO_PERMISSION_DENIED, - summary: "Permission denied", - description: "You don't have permission to access the specified file or directory.", - why_it_matters: "Permission errors prevent Lash from reading or writing files.", - how_to_fix: "Check file permissions and adjust them if needed, or run Lash with appropriate permissions.", - example_bad: None, - example_good: None, - }), - - codes::E_IO_INVALID_PATH => Some(ErrorExplanation { - code: codes::E_IO_INVALID_PATH, - summary: "Invalid path", - description: "The specified path is invalid or contains illegal characters.", - why_it_matters: "Invalid paths can't be used to access files.", - how_to_fix: "Check that the path is properly formatted and doesn't contain invalid characters for your operating system.", - example_bad: None, - example_good: None, - }), - - // ===== Internal Errors ===== - codes::E_INTERNAL => Some(ErrorExplanation { - code: codes::E_INTERNAL, - summary: "Internal error", - description: "An unexpected internal error occurred. This is likely a bug in Lash.", - why_it_matters: "Internal errors indicate bugs that should be reported to the Lash developers.", - how_to_fix: "Please report this error as a bug on the Lash issue tracker, including the full error message and steps to reproduce.", - example_bad: None, - example_good: None, - }), - - // ===== Task Creation Errors ===== - codes::E_CREATE_EMPTY_TITLE => Some(ErrorExplanation { - code: codes::E_CREATE_EMPTY_TITLE, - summary: "Task title is empty", - description: "The task title provided is empty or contains only whitespace. Every task requires a meaningful title to identify it.", - why_it_matters: "Tasks without titles cannot be identified or tracked. The title is the primary way users and agents reference tasks.", - how_to_fix: "Provide a non-empty, descriptive title for the task. A good title clearly describes what needs to be done.", - example_bad: Some("lash add \"\"\nlash add \" \""), - example_good: Some("lash add \"Implement user authentication\"\nlash add \"Fix login page CSS\""), - }), - - codes::E_CREATE_TITLE_TOO_LONG => Some(ErrorExplanation { - code: codes::E_CREATE_TITLE_TOO_LONG, - summary: "Task title exceeds maximum length", - description: "The task title is longer than the maximum allowed length of 200 characters. Long titles become unwieldy in displays and reports.", - why_it_matters: "Excessively long titles make task lists hard to read and can cause display issues in the TUI and other interfaces.", - how_to_fix: "Shorten the title to 200 characters or fewer. Move detailed information to the task description or agent note instead.", - example_bad: Some("lash add \"This is an extremely long task title that goes into way too much detail about what needs to be done when really it should be a short summary...\""), - example_good: Some("lash add \"Implement caching layer\" --agent-note \"Consider Redis or memcached for distributed caching\""), - }), - - codes::E_CREATE_FILE_NOT_FOUND => Some(ErrorExplanation { - code: codes::E_CREATE_FILE_NOT_FOUND, - summary: "Target file does not exist", - description: "The file specified with --file does not exist. When using an explicit file path, the file must exist unless you're creating a new file.", - why_it_matters: "Tasks can only be added to existing, valid Lash task files. Creating tasks in non-existent files would fail.", - how_to_fix: "Either create the file first, or use --file with --file-title to automatically create a new task file.", - example_bad: Some("lash add \"My task\" --file nonexistent.md"), - example_good: Some("lash add \"My task\" --file tasks/new-feature.md --file-title \"New Feature Tasks\""), - }), - - codes::E_CREATE_FILE_NOT_WRITABLE => Some(ErrorExplanation { - code: codes::E_CREATE_FILE_NOT_WRITABLE, - summary: "Target file is not writable", - description: "The target file exists but cannot be written to. This usually means the file is read-only or you don't have write permissions.", - why_it_matters: "Lash needs write access to add tasks to a file. Without it, the task cannot be saved.", - how_to_fix: "Check the file permissions and ensure you have write access. On Unix systems, use chmod to add write permissions if needed.", - example_bad: None, - example_good: None, - }), - - codes::E_CREATE_FILE_PARSE_FAILED => Some(ErrorExplanation { - code: codes::E_CREATE_FILE_PARSE_FAILED, - summary: "Target file failed to parse", - description: "The target file exists but contains invalid syntax that Lash cannot parse. The file may be corrupted or not follow the expected format.", - why_it_matters: "Lash must understand the file structure to safely add a task without breaking existing content.", - how_to_fix: "Run `lash lint ` to see the parsing errors and fix them before adding new tasks.", - example_bad: None, - example_good: None, - }), - - codes::E_CREATE_PARENT_NOT_FOUND => Some(ErrorExplanation { - code: codes::E_CREATE_PARENT_NOT_FOUND, - summary: "Parent task not found", - description: "The parent task specified with --parent does not exist in the target file. Subtasks must have valid parent tasks.", - why_it_matters: "Creating a subtask requires a valid parent. Without one, the task hierarchy would be broken.", - how_to_fix: "Ensure the parent task ID exists in the file, or omit --parent to create a top-level task.", - example_bad: Some("lash add \"Subtask\" --parent nonexistent-parent"), - example_good: Some("lash add \"Subtask\" --parent existing-task-id\nlash add \"Top-level task\" # No parent needed"), - }), - - codes::E_CREATE_DEPTH_LIMIT_EXCEEDED => Some(ErrorExplanation { - code: codes::E_CREATE_DEPTH_LIMIT_EXCEEDED, - summary: "Task would exceed maximum nesting depth", - description: "Creating this task as a subtask of the specified parent would exceed the maximum nesting depth (default: 3 levels).", - why_it_matters: "Excessive nesting makes task files hard to read and manage. It often indicates tasks should be reorganized or split into separate files.", - how_to_fix: "Choose a parent task at a shallower depth, create a top-level task instead, or reorganize tasks into separate files.", - example_bad: Some("# Already at depth 3:\n- [ ] Level 1\n - [ ] Level 2\n - [ ] Level 3\n # Cannot add here"), - example_good: Some("# Create at shallower level or as top-level:\nlash add \"New task\" # Top-level\nlash add \"New task\" --parent level-1-task"), - }), - - codes::E_CREATE_DUPLICATE_ID => Some(ErrorExplanation { - code: codes::E_CREATE_DUPLICATE_ID, - summary: "Task ID is already in use", - description: "The task ID specified with --id is already used by another task in the same file. Task IDs must be unique within each file.", - why_it_matters: "Duplicate IDs make it impossible to uniquely reference tasks, breaking dependencies and cross-references.", - how_to_fix: "Choose a different, unique ID for the task, or omit --id to let Lash auto-generate one from the title.", - example_bad: Some("# If 'setup' already exists:\nlash add \"Another task\" --id setup"), - example_good: Some("lash add \"Another task\" --id setup-phase-2\nlash add \"Another task\" # Auto-generates ID"), - }), - - codes::E_CREATE_INVALID_ID_FORMAT => Some(ErrorExplanation { - code: codes::E_CREATE_INVALID_ID_FORMAT, - summary: "Task ID format is invalid", - description: "The task ID contains invalid characters. IDs must contain only alphanumeric characters, hyphens, underscores, and colons.", - why_it_matters: "Valid IDs are necessary for reliable cross-referencing and URL-safe task references.", - how_to_fix: "Use only letters, numbers, hyphens (-), underscores (_), and colons (:) in task IDs.", - example_bad: Some("lash add \"Task\" --id \"my task!\"\nlash add \"Task\" --id \"task with spaces\""), - example_good: Some("lash add \"Task\" --id my-task\nlash add \"Task\" --id task_v2\nlash add \"Task\" --id module:feature"), - }), - - codes::E_CREATE_INVALID_LABEL => Some(ErrorExplanation { - code: codes::E_CREATE_INVALID_LABEL, - summary: "Label format is invalid", - description: "One or more labels contain invalid characters. Labels must be alphanumeric with hyphens only, no spaces or special characters.", - why_it_matters: "Consistent label formatting ensures reliable filtering and searching across tasks.", - how_to_fix: "Use only letters, numbers, and hyphens in labels. Replace spaces with hyphens.", - example_bad: Some("lash add \"Task\" --label \"my label\"\nlash add \"Task\" --label \"urgent!\""), - example_good: Some("lash add \"Task\" --label my-label\nlash add \"Task\" --label urgent --label backend"), - }), - - codes::E_CREATE_INVALID_ESTIMATE => Some(ErrorExplanation { - code: codes::E_CREATE_INVALID_ESTIMATE, - summary: "Time estimate format is invalid", - description: "The time estimate doesn't follow a recognized format. Estimates should use units like minutes (m), hours (h), days (d), or weeks (w).", - why_it_matters: "Valid time estimates enable sprint planning and progress tracking features.", - how_to_fix: "Use formats like: 30m (minutes), 2h (hours), 1d (days), 1w (weeks), or combined: 2d 4h.", - example_bad: Some("lash add \"Task\" --estimate \"two hours\"\nlash add \"Task\" --estimate \"long\""), - example_good: Some("lash add \"Task\" --estimate 2h\nlash add \"Task\" --estimate 1d\nlash add \"Task\" --estimate \"2d 4h\""), - }), - - codes::E_CREATE_DEPENDENCY_NOT_FOUND => Some(ErrorExplanation { - code: codes::E_CREATE_DEPENDENCY_NOT_FOUND, - summary: "Dependency reference not found", - description: "A dependency specified with --depends-on references a task that doesn't exist or cannot be found.", - why_it_matters: "Dependencies must point to valid tasks. Broken dependencies cause incorrect blocking status.", - how_to_fix: "Verify the referenced task exists. Use format: path/to/file.md#task:id for cross-file references.", - example_bad: Some("lash add \"Task\" --depends-on nonexistent.md#task:missing"), - example_good: Some("lash add \"Task\" --depends-on tasks/setup.md#task:database-init"), - }), - - codes::E_CREATE_WOULD_CREATE_CYCLE => Some(ErrorExplanation { - code: codes::E_CREATE_WOULD_CREATE_CYCLE, - summary: "Would create circular dependency", - description: "Creating this task with the specified dependencies would create a circular dependency chain where tasks depend on each other in a loop.", - why_it_matters: "Circular dependencies create logical impossibilities - no task in the cycle can ever be started or completed.", - how_to_fix: "Remove the dependency that creates the cycle, or restructure the task hierarchy to break the circular reference.", - example_bad: Some("# Task A depends on B, B depends on A:\nlash add \"Task A\" --depends-on file.md#task:b\nlash add \"Task B\" --depends-on file.md#task:a"), - example_good: Some("# Linear dependency chain:\nlash add \"Task A\" --depends-on file.md#task:b\nlash add \"Task B\" # No circular reference"), - }), - - codes::E_CREATE_INVALID_POSITION => Some(ErrorExplanation { - code: codes::E_CREATE_INVALID_POSITION, - summary: "Insert position is invalid", - description: "The position specified with --before or --after references a task that doesn't exist in the target file, lives at a different nesting level, or belongs to another file.", - why_it_matters: "Task ordering requires valid position references to maintain the correct sequence.", - how_to_fix: "Use a task ID from the target file. Both the bare ID and the qualified 'file#id' form that `lash show` prints are accepted, but the file part must name the file you are adding to. Omit these options to append at the end.", - example_bad: Some("lash add \"Task\" --after nonexistent-task\nlash add \"Task\" -f a.md --after other-file#some-task"), - example_good: Some("lash add \"Task\" --after existing-task-id\nlash add \"Task\" -f lash.index.md --after index#existing-task-id\nlash add \"Task\" # Appends at end"), - }), - - codes::E_CREATE_IO_ERROR => Some(ErrorExplanation { - code: codes::E_CREATE_IO_ERROR, - summary: "I/O error during task creation", - description: "An input/output error occurred while trying to write the task to the file. This could be due to disk issues, permissions, or system problems.", - why_it_matters: "The task could not be saved due to a system-level I/O problem.", - how_to_fix: "Check that you have write permissions, sufficient disk space, and that the file system is accessible.", - example_bad: None, - example_good: None, - }), - - _ => None, - } -} - -/// Get all available error codes that have explanations -#[must_use] -pub fn all_error_codes() -> Vec<&'static str> { - vec![ - // Parse errors - codes::E_PARSE_INVALID_CHECKBOX, - codes::E_PARSE_INVALID_ANNOTATION, - codes::E_PARSE_INVALID_HEADER, - codes::E_PARSE_UNEXPECTED_DEPTH, - codes::E_PARSE_INVALID_DATE, - // Lint errors - codes::E_LINT_DUPLICATE_ID, - codes::E_LINT_UNKNOWN_ANNOTATION, - codes::E_LINT_DEPTH_EXCEEDED, - codes::E_LINT_STATUS_INCONSISTENCY, - codes::E_LINT_INVALID_LABEL, - codes::E_LINT_MISSING_ANNOTATION, - codes::E_LINT_BAD_INDENTATION, - codes::W_SEM_DOC_FRAGMENT, - // Dependency errors - codes::E_DEP_NOT_FOUND, - codes::E_DEP_CYCLE, - codes::E_DEP_INVALID_REF, - // Index errors - codes::E_INDEX_CORRUPTED, - codes::E_INDEX_VERSION_MISMATCH, - codes::E_INDEX_OUT_OF_SYNC, - // Query errors - codes::E_QUERY_INVALID_SYNTAX, - codes::E_QUERY_NO_RESULTS, - // Config errors - codes::E_CONFIG_ROOT_NOT_FOUND, - codes::E_CONFIG_INVALID_VALUE, - codes::E_CONFIG_PARSE_ERROR, - codes::E_CONFIG_MISSING_INDEX, - // IO errors - codes::E_IO_FILE_NOT_FOUND, - codes::E_IO_READ_ERROR, - codes::E_IO_WRITE_ERROR, - codes::E_IO_PERMISSION_DENIED, - codes::E_IO_INVALID_PATH, - // Internal errors - codes::E_INTERNAL, - // Task creation errors - codes::E_CREATE_EMPTY_TITLE, - codes::E_CREATE_TITLE_TOO_LONG, - codes::E_CREATE_FILE_NOT_FOUND, - codes::E_CREATE_FILE_NOT_WRITABLE, - codes::E_CREATE_FILE_PARSE_FAILED, - codes::E_CREATE_PARENT_NOT_FOUND, - codes::E_CREATE_DEPTH_LIMIT_EXCEEDED, - codes::E_CREATE_DUPLICATE_ID, - codes::E_CREATE_INVALID_ID_FORMAT, - codes::E_CREATE_INVALID_LABEL, - codes::E_CREATE_INVALID_ESTIMATE, - codes::E_CREATE_DEPENDENCY_NOT_FOUND, - codes::E_CREATE_WOULD_CREATE_CYCLE, - codes::E_CREATE_INVALID_POSITION, - codes::E_CREATE_IO_ERROR, - ] -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_all_codes_have_explanations() { - for code in all_error_codes() { - let explanation = explain_error(code); - assert!( - explanation.is_some(), - "Error code {code} is listed but has no explanation" - ); - } - } - - #[test] - fn test_explanation_markdown_format() { - let explanation = explain_error(codes::E_PARSE_INVALID_CHECKBOX).unwrap(); - let markdown = explanation.to_markdown(); - - assert!(markdown.contains("# Error:")); - assert!(markdown.contains(codes::E_PARSE_INVALID_CHECKBOX)); - assert!(markdown.contains("Description:")); - assert!(markdown.contains("Why it matters:")); - assert!(markdown.contains("How to fix:")); - } - - #[test] - fn test_unknown_code_returns_none() { - let explanation = explain_error("E_UNKNOWN_CODE"); - assert!(explanation.is_none()); - } - - #[test] - fn test_parse_errors_have_examples() { - let codes_with_examples = [ - codes::E_PARSE_INVALID_CHECKBOX, - codes::E_PARSE_INVALID_ANNOTATION, - codes::E_PARSE_INVALID_HEADER, - ]; - - for code in codes_with_examples { - let explanation = explain_error(code).unwrap(); - assert!( - explanation.example_bad.is_some(), - "{code} should have bad example" - ); - assert!( - explanation.example_good.is_some(), - "{code} should have good example" - ); - } - } -} diff --git a/crates/lash-types/src/error_explanations/creation.rs b/crates/lash-types/src/error_explanations/creation.rs new file mode 100644 index 0000000..65652c8 --- /dev/null +++ b/crates/lash-types/src/error_explanations/creation.rs @@ -0,0 +1,180 @@ +//! Explanations for task creation error codes (`E_CREATE_*`) + +use super::ErrorExplanation; +use crate::error::codes; + +/// Codes explained by this module +pub(super) const CODES: &[&str] = &[ + codes::E_CREATE_EMPTY_TITLE, + codes::E_CREATE_TITLE_TOO_LONG, + codes::E_CREATE_FILE_NOT_FOUND, + codes::E_CREATE_FILE_NOT_WRITABLE, + codes::E_CREATE_FILE_PARSE_FAILED, + codes::E_CREATE_PARENT_NOT_FOUND, + codes::E_CREATE_DEPTH_LIMIT_EXCEEDED, + codes::E_CREATE_DUPLICATE_ID, + codes::E_CREATE_INVALID_ID_FORMAT, + codes::E_CREATE_INVALID_LABEL, + codes::E_CREATE_INVALID_ESTIMATE, + codes::E_CREATE_DEPENDENCY_NOT_FOUND, + codes::E_CREATE_WOULD_CREATE_CYCLE, + codes::E_CREATE_INVALID_POSITION, + codes::E_CREATE_IO_ERROR, +]; + +/// Look up a task creation error explanation +pub(super) fn explain(code: &str) -> Option { + match code { + // ===== Task Creation Errors ===== + codes::E_CREATE_EMPTY_TITLE => Some(ErrorExplanation { + code: codes::E_CREATE_EMPTY_TITLE, + summary: "Task title is empty", + description: "The task title provided is empty or contains only whitespace. Every task requires a meaningful title to identify it.", + why_it_matters: "Tasks without titles cannot be identified or tracked. The title is the primary way users and agents reference tasks.", + how_to_fix: "Provide a non-empty, descriptive title for the task. A good title clearly describes what needs to be done.", + example_bad: Some("lash add \"\"\nlash add \" \""), + example_good: Some("lash add \"Implement user authentication\"\nlash add \"Fix login page CSS\""), + }), + + codes::E_CREATE_TITLE_TOO_LONG => Some(ErrorExplanation { + code: codes::E_CREATE_TITLE_TOO_LONG, + summary: "Task title exceeds maximum length", + description: "The task title is longer than the maximum allowed length of 200 characters. Long titles become unwieldy in displays and reports.", + why_it_matters: "Excessively long titles make task lists hard to read and can cause display issues in the TUI and other interfaces.", + how_to_fix: "Shorten the title to 200 characters or fewer. Move detailed information to the task description or agent note instead.", + example_bad: Some("lash add \"This is an extremely long task title that goes into way too much detail about what needs to be done when really it should be a short summary...\""), + example_good: Some("lash add \"Implement caching layer\" --agent-note \"Consider Redis or memcached for distributed caching\""), + }), + + codes::E_CREATE_FILE_NOT_FOUND => Some(ErrorExplanation { + code: codes::E_CREATE_FILE_NOT_FOUND, + summary: "Target file does not exist", + description: "The file specified with --file does not exist. When using an explicit file path, the file must exist unless you're creating a new file.", + why_it_matters: "Tasks can only be added to existing, valid Lash task files. Creating tasks in non-existent files would fail.", + how_to_fix: "Either create the file first, or use --file with --file-title to automatically create a new task file.", + example_bad: Some("lash add \"My task\" --file nonexistent.md"), + example_good: Some("lash add \"My task\" --file tasks/new-feature.md --file-title \"New Feature Tasks\""), + }), + + codes::E_CREATE_FILE_NOT_WRITABLE => Some(ErrorExplanation { + code: codes::E_CREATE_FILE_NOT_WRITABLE, + summary: "Target file is not writable", + description: "The target file exists but cannot be written to. This usually means the file is read-only or you don't have write permissions.", + why_it_matters: "Lash needs write access to add tasks to a file. Without it, the task cannot be saved.", + how_to_fix: "Check the file permissions and ensure you have write access. On Unix systems, use chmod to add write permissions if needed.", + example_bad: None, + example_good: None, + }), + + codes::E_CREATE_FILE_PARSE_FAILED => Some(ErrorExplanation { + code: codes::E_CREATE_FILE_PARSE_FAILED, + summary: "Target file failed to parse", + description: "The target file exists but contains invalid syntax that Lash cannot parse. The file may be corrupted or not follow the expected format.", + why_it_matters: "Lash must understand the file structure to safely add a task without breaking existing content.", + how_to_fix: "Run `lash lint ` to see the parsing errors and fix them before adding new tasks.", + example_bad: None, + example_good: None, + }), + + codes::E_CREATE_PARENT_NOT_FOUND => Some(ErrorExplanation { + code: codes::E_CREATE_PARENT_NOT_FOUND, + summary: "Parent task not found", + description: "The parent task specified with --parent does not exist in the target file. Subtasks must have valid parent tasks.", + why_it_matters: "Creating a subtask requires a valid parent. Without one, the task hierarchy would be broken.", + how_to_fix: "Ensure the parent task ID exists in the file, or omit --parent to create a top-level task.", + example_bad: Some("lash add \"Subtask\" --parent nonexistent-parent"), + example_good: Some("lash add \"Subtask\" --parent existing-task-id\nlash add \"Top-level task\" # No parent needed"), + }), + + codes::E_CREATE_DEPTH_LIMIT_EXCEEDED => Some(ErrorExplanation { + code: codes::E_CREATE_DEPTH_LIMIT_EXCEEDED, + summary: "Task would exceed maximum nesting depth", + description: "Creating this task as a subtask of the specified parent would exceed the maximum nesting depth (default: 3 levels).", + why_it_matters: "Excessive nesting makes task files hard to read and manage. It often indicates tasks should be reorganized or split into separate files.", + how_to_fix: "Choose a parent task at a shallower depth, create a top-level task instead, or reorganize tasks into separate files.", + example_bad: Some("# Already at depth 3:\n- [ ] Level 1\n - [ ] Level 2\n - [ ] Level 3\n # Cannot add here"), + example_good: Some("# Create at shallower level or as top-level:\nlash add \"New task\" # Top-level\nlash add \"New task\" --parent level-1-task"), + }), + + codes::E_CREATE_DUPLICATE_ID => Some(ErrorExplanation { + code: codes::E_CREATE_DUPLICATE_ID, + summary: "Task ID is already in use", + description: "The task ID specified with --id is already used by another task in the same file. Task IDs must be unique within each file.", + why_it_matters: "Duplicate IDs make it impossible to uniquely reference tasks, breaking dependencies and cross-references.", + how_to_fix: "Choose a different, unique ID for the task, or omit --id to let Lash auto-generate one from the title.", + example_bad: Some("# If 'setup' already exists:\nlash add \"Another task\" --id setup"), + example_good: Some("lash add \"Another task\" --id setup-phase-2\nlash add \"Another task\" # Auto-generates ID"), + }), + + codes::E_CREATE_INVALID_ID_FORMAT => Some(ErrorExplanation { + code: codes::E_CREATE_INVALID_ID_FORMAT, + summary: "Task ID format is invalid", + description: "The task ID contains invalid characters. IDs must contain only alphanumeric characters, hyphens, underscores, and colons.", + why_it_matters: "Valid IDs are necessary for reliable cross-referencing and URL-safe task references.", + how_to_fix: "Use only letters, numbers, hyphens (-), underscores (_), and colons (:) in task IDs.", + example_bad: Some("lash add \"Task\" --id \"my task!\"\nlash add \"Task\" --id \"task with spaces\""), + example_good: Some("lash add \"Task\" --id my-task\nlash add \"Task\" --id task_v2\nlash add \"Task\" --id module:feature"), + }), + + codes::E_CREATE_INVALID_LABEL => Some(ErrorExplanation { + code: codes::E_CREATE_INVALID_LABEL, + summary: "Label format is invalid", + description: "One or more labels contain invalid characters. Labels must be alphanumeric with hyphens only, no spaces or special characters.", + why_it_matters: "Consistent label formatting ensures reliable filtering and searching across tasks.", + how_to_fix: "Use only letters, numbers, and hyphens in labels. Replace spaces with hyphens.", + example_bad: Some("lash add \"Task\" --label \"my label\"\nlash add \"Task\" --label \"urgent!\""), + example_good: Some("lash add \"Task\" --label my-label\nlash add \"Task\" --label urgent --label backend"), + }), + + codes::E_CREATE_INVALID_ESTIMATE => Some(ErrorExplanation { + code: codes::E_CREATE_INVALID_ESTIMATE, + summary: "Time estimate format is invalid", + description: "The time estimate doesn't follow a recognized format. Estimates should use units like minutes (m), hours (h), days (d), or weeks (w).", + why_it_matters: "Valid time estimates enable sprint planning and progress tracking features.", + how_to_fix: "Use formats like: 30m (minutes), 2h (hours), 1d (days), 1w (weeks), or combined: 2d 4h.", + example_bad: Some("lash add \"Task\" --estimate \"two hours\"\nlash add \"Task\" --estimate \"long\""), + example_good: Some("lash add \"Task\" --estimate 2h\nlash add \"Task\" --estimate 1d\nlash add \"Task\" --estimate \"2d 4h\""), + }), + + codes::E_CREATE_DEPENDENCY_NOT_FOUND => Some(ErrorExplanation { + code: codes::E_CREATE_DEPENDENCY_NOT_FOUND, + summary: "Dependency reference not found", + description: "A dependency specified with --depends-on references a task that doesn't exist or cannot be found.", + why_it_matters: "Dependencies must point to valid tasks. Broken dependencies cause incorrect blocking status.", + how_to_fix: "Verify the referenced task exists. Use format: path/to/file.md#task:id for cross-file references.", + example_bad: Some("lash add \"Task\" --depends-on nonexistent.md#task:missing"), + example_good: Some("lash add \"Task\" --depends-on tasks/setup.md#task:database-init"), + }), + + codes::E_CREATE_WOULD_CREATE_CYCLE => Some(ErrorExplanation { + code: codes::E_CREATE_WOULD_CREATE_CYCLE, + summary: "Would create circular dependency", + description: "Creating this task with the specified dependencies would create a circular dependency chain where tasks depend on each other in a loop.", + why_it_matters: "Circular dependencies create logical impossibilities - no task in the cycle can ever be started or completed.", + how_to_fix: "Remove the dependency that creates the cycle, or restructure the task hierarchy to break the circular reference.", + example_bad: Some("# Task A depends on B, B depends on A:\nlash add \"Task A\" --depends-on file.md#task:b\nlash add \"Task B\" --depends-on file.md#task:a"), + example_good: Some("# Linear dependency chain:\nlash add \"Task A\" --depends-on file.md#task:b\nlash add \"Task B\" # No circular reference"), + }), + + codes::E_CREATE_INVALID_POSITION => Some(ErrorExplanation { + code: codes::E_CREATE_INVALID_POSITION, + summary: "Insert position is invalid", + description: "The position specified with --before or --after references a task that doesn't exist in the target file, lives at a different nesting level, or belongs to another file.", + why_it_matters: "Task ordering requires valid position references to maintain the correct sequence.", + how_to_fix: "Use a task ID from the target file. Both the bare ID and the qualified 'file#id' form that `lash show` prints are accepted, but the file part must name the file you are adding to. Omit these options to append at the end.", + example_bad: Some("lash add \"Task\" --after nonexistent-task\nlash add \"Task\" -f a.md --after other-file#some-task"), + example_good: Some("lash add \"Task\" --after existing-task-id\nlash add \"Task\" -f lash.index.md --after index#existing-task-id\nlash add \"Task\" # Appends at end"), + }), + + codes::E_CREATE_IO_ERROR => Some(ErrorExplanation { + code: codes::E_CREATE_IO_ERROR, + summary: "I/O error during task creation", + description: "An input/output error occurred while trying to write the task to the file. This could be due to disk issues, permissions, or system problems.", + why_it_matters: "The task could not be saved due to a system-level I/O problem.", + how_to_fix: "Check that you have write permissions, sufficient disk space, and that the file system is accessible.", + example_bad: None, + example_good: None, + }), + _ => None, + } +} diff --git a/crates/lash-types/src/error_explanations/crossfile.rs b/crates/lash-types/src/error_explanations/crossfile.rs new file mode 100644 index 0000000..1b0385d --- /dev/null +++ b/crates/lash-types/src/error_explanations/crossfile.rs @@ -0,0 +1,74 @@ +//! Explanations for the linter's cross-file rules +//! +//! These are the codes `lash lint` emits when it compares files against each +//! other: dependency links (`E_LINK_*`) and root-index coverage +//! (`E_INDEX_FILE_MISSING`, `W_INDEX_ORPHAN`). + +use super::ErrorExplanation; +use crate::error::codes; + +/// Codes explained by this module +pub(super) const CODES: &[&str] = &[ + codes::E_LINK_NOT_FOUND, + codes::E_LINK_CYCLE, + codes::E_LINK_INVALID_PATH, + codes::E_INDEX_FILE_MISSING, + codes::W_INDEX_ORPHAN, +]; + +/// Look up a cross-file rule explanation +pub(super) fn explain(code: &str) -> Option { + match code { + codes::E_LINK_NOT_FOUND => Some(ErrorExplanation { + code: codes::E_LINK_NOT_FOUND, + summary: "`@depends-on:` target file or task does not exist", + description: "A dependency reference points at a file that is not in the project, or at a task ID that file does not contain. References take the form `path/to/file.md#task:id`, resolved relative to the file holding the annotation.", + why_it_matters: "An unresolvable dependency is not tracked: the dependent task is never reported as blocked, so `lash list --blocked` and the graph both understate what is waiting on what.", + how_to_fix: "Check the path and the task ID — `lash show ` prints the qualified form to copy. If the target task still exists but its ID changed, run `lash migrate-ids` to rewrite the references. `lash check-links` lists every broken reference at once.", + example_bad: Some("@depends-on: tasks/missing.md#task:setup\n@depends-on: tasks/setup.md#task:no-such-id"), + example_good: Some("@depends-on: tasks/setup.md#task:database-setup"), + }), + + codes::E_LINK_CYCLE => Some(ErrorExplanation { + code: codes::E_LINK_CYCLE, + summary: "Dependency references form a cycle", + description: "Following `@depends-on:` links leads back to where it started: A waits on B, B waits on C, C waits on A. The diagnostic prints the cycle path.", + why_it_matters: "Every task in a cycle is permanently blocked by another task in the cycle, so none of them can ever be started. Cycles also make any dependency ordering impossible to compute.", + how_to_fix: "Drop the one dependency that closes the loop, or split the task that appears twice into the part that comes first and the part that comes later.", + example_bad: Some("# a.md\n- [ ] A\n @depends-on: b.md#task:b\n\n# b.md\n- [ ] B\n @depends-on: a.md#task:a"), + example_good: Some("# a.md\n- [ ] A\n @depends-on: b.md#task:b\n\n# b.md\n- [ ] B # no dependency back on A"), + }), + + codes::E_LINK_INVALID_PATH => Some(ErrorExplanation { + code: codes::E_LINK_INVALID_PATH, + summary: "Dependency path is malformed or escapes the project root", + description: "The path part of a dependency reference is not well-formed, or it climbs out of the project with `../` segments. Dependencies may only point at files inside the project.", + why_it_matters: "A reference outside the project cannot be indexed or verified, and it makes the task file non-portable — the link breaks for anyone who checks the project out somewhere else.", + how_to_fix: "Use a path inside the project, relative to the file containing the annotation. If the target genuinely lives outside the project, link it as documentation with `@doc:` instead of as a dependency.", + example_bad: Some("@depends-on: ../../other-project/tasks.md#task:setup\n@depends-on: /absolute/path.md#task:setup"), + example_good: Some("@depends-on: ../shared/tasks.md#task:setup"), + }), + + codes::E_INDEX_FILE_MISSING => Some(ErrorExplanation { + code: codes::E_INDEX_FILE_MISSING, + summary: "Root index references a file that does not exist", + description: "An entry in the root index (`lash.index.md` or `index.lash.md`) points at a Markdown file that is not on disk. This is the mirror image of W_INDEX_ORPHAN: there the file exists but is not listed, here it is listed but does not exist.", + why_it_matters: "The root index is the map of the project. An entry pointing at nothing sends readers and agents to a file that is not there, usually after a rename or delete that missed the index.", + how_to_fix: "Fix the path if the file moved, or remove the entry if the file is gone.", + example_bad: Some("## Tasks\n\n- [ ] [Backend](tasks/backend.md) # file was renamed to tasks/api.md"), + example_good: Some("## Tasks\n\n- [ ] [Backend](tasks/api.md)"), + }), + + codes::W_INDEX_ORPHAN => Some(ErrorExplanation { + code: codes::W_INDEX_ORPHAN, + summary: "Markdown file is not referenced in the root index", + description: "Lash found a Markdown file in the project that the root index (`lash.index.md` or `index.lash.md`) does not link to. Common documentation names (README.md, CHANGELOG.md, CONTRIBUTING.md, devlog.md and similar) and files under `docs/`, `doc/`, `documentation/` and `.github/` are exempt already.", + why_it_matters: "The root index is how humans and agents discover task files. A task file missing from it is invisible to anyone starting from the index — and it is easy to create one by adding a file and forgetting the index entry.", + how_to_fix: "Add a link to the file in the root index. If the file is not a task file at all — prose, notes, generated content — add its path or directory to a `.lashignore` at the project root and Lash will stop walking it entirely (one glob per line, `.gitignore` syntax, so `content/` excludes a whole directory).", + example_bad: Some("# project layout\nlash.index.md # links only tasks/backend.md\ntasks/backend.md\ncontent/a-post.md # warns: prose, never a task file"), + example_good: Some("# .lashignore\ncontent/\n\n# or, in lash.index.md:\n- [ ] [Notes](content/a-post.md)"), + }), + + _ => None, + } +} diff --git a/crates/lash-types/src/error_explanations/legacy_lint.rs b/crates/lash-types/src/error_explanations/legacy_lint.rs new file mode 100644 index 0000000..1ecd701 --- /dev/null +++ b/crates/lash-types/src/error_explanations/legacy_lint.rs @@ -0,0 +1,96 @@ +//! Explanations for the legacy generic lint codes (`E_LINT_*`) +//! +//! These predate the per-rule codes in [`super::syntax`], [`super::semantic`] +//! and [`super::crossfile`]. They are still accepted by `lash explain` so that +//! older diagnostics, scripts and docs keep resolving. + +use super::ErrorExplanation; +use crate::error::codes; + +/// Codes explained by this module +pub(super) const CODES: &[&str] = &[ + codes::E_LINT_DUPLICATE_ID, + codes::E_LINT_UNKNOWN_ANNOTATION, + codes::E_LINT_DEPTH_EXCEEDED, + codes::E_LINT_STATUS_INCONSISTENCY, + codes::E_LINT_INVALID_LABEL, + codes::E_LINT_MISSING_ANNOTATION, + codes::E_LINT_BAD_INDENTATION, +]; + +/// Look up a legacy lint error explanation +pub(super) fn explain(code: &str) -> Option { + match code { + // ===== Lint Errors ===== + codes::E_LINT_DUPLICATE_ID => Some(ErrorExplanation { + code: codes::E_LINT_DUPLICATE_ID, + summary: "Duplicate task ID", + description: "Two or more tasks in the same file have the same @id annotation. Task IDs must be unique within a file.", + why_it_matters: "Duplicate IDs make it impossible to reference specific tasks unambiguously, breaking dependency links and task lookups.", + how_to_fix: "Rename one of the duplicate IDs to a unique value. Choose descriptive, unique identifiers for each task.", + example_bad: Some("- [ ] First task\n @id: setup\n\n- [ ] Second task\n @id: setup"), + example_good: Some("- [ ] First task\n @id: setup-database\n\n- [ ] Second task\n @id: setup-server"), + }), + + codes::E_LINT_UNKNOWN_ANNOTATION => Some(ErrorExplanation { + code: codes::E_LINT_UNKNOWN_ANNOTATION, + summary: "Unknown annotation", + description: "The annotation used is not recognized by Lash. Valid annotations are: @id, @labels, @owner, @estimate, @depends-on, @created, @doc, and @agent-note.", + why_it_matters: "Unknown annotations are ignored by Lash, which may indicate a typo or misunderstanding of the annotation system.", + how_to_fix: "Check the annotation name for typos, or remove it if it's not needed. Refer to the documentation for the list of valid annotations.", + example_bad: Some("@task-id: my-task\n@priority: high"), + example_good: Some("@id: my-task\n@labels: priority-high"), + }), + + codes::E_LINT_DEPTH_EXCEEDED => Some(ErrorExplanation { + code: codes::E_LINT_DEPTH_EXCEEDED, + summary: "Task nesting exceeds maximum depth", + description: "Tasks are nested too deeply. The maximum recommended depth is 4 levels (parent, child, grandchild, great-grandchild).", + why_it_matters: "Excessive nesting makes task files hard to read and maintain. It often indicates that tasks should be broken into separate files.", + how_to_fix: "Flatten the task hierarchy by moving deeply nested tasks to a separate file or restructuring the task breakdown.", + example_bad: Some("- [ ] Level 1\n - [ ] Level 2\n - [ ] Level 3\n - [ ] Level 4\n - [ ] Level 5 (too deep)"), + example_good: Some("- [ ] High-level task\n @depends-on: detailed-tasks.md#task:setup\n\n(Move details to detailed-tasks.md)"), + }), + + codes::E_LINT_STATUS_INCONSISTENCY => Some(ErrorExplanation { + code: codes::E_LINT_STATUS_INCONSISTENCY, + summary: "Parent task marked done with incomplete children", + description: "A parent task is marked as done [x] but has child tasks that are still open [ ].", + why_it_matters: "This creates logical inconsistency in your task hierarchy. A parent can only be complete when all its children are complete or waived.", + how_to_fix: "Either mark all child tasks as done/waived, or change the parent status to open or blocked.", + example_bad: Some("- [x] Complete feature\n - [ ] Write code\n - [ ] Write tests"), + example_good: Some("- [ ] Complete feature\n - [x] Write code\n - [x] Write tests\n\nOR\n\n- [x] Complete feature\n - [x] Write code\n - [x] Write tests"), + }), + + codes::E_LINT_INVALID_LABEL => Some(ErrorExplanation { + code: codes::E_LINT_INVALID_LABEL, + summary: "Invalid label format", + description: "A label contains invalid characters. Labels must be alphanumeric with hyphens, and multiple labels should be comma-separated.", + why_it_matters: "Invalid labels can't be used for filtering and may cause parsing errors.", + how_to_fix: "Use only letters, numbers, and hyphens in labels. Separate multiple labels with commas.", + example_bad: Some("@labels: front-end!, back_end\n@labels: high priority"), + example_good: Some("@labels: front-end, back-end\n@labels: high-priority"), + }), + + codes::E_LINT_MISSING_ANNOTATION => Some(ErrorExplanation { + code: codes::E_LINT_MISSING_ANNOTATION, + summary: "Missing required annotation", + description: "A task is missing a required annotation, typically @id. Some projects require certain annotations for proper tracking.", + why_it_matters: "Missing required annotations prevent proper task identification and cross-referencing.", + how_to_fix: "Add the required annotation to the task. For @id, choose a unique, descriptive identifier.", + example_bad: Some("- [ ] My task without ID"), + example_good: Some("- [ ] My task\n @id: my-task-id"), + }), + + codes::E_LINT_BAD_INDENTATION => Some(ErrorExplanation { + code: codes::E_LINT_BAD_INDENTATION, + summary: "Incorrect indentation", + description: "The indentation doesn't match the expected 2-space increments for task nesting.", + why_it_matters: "Inconsistent indentation breaks the task hierarchy and makes files hard to read.", + how_to_fix: "Run `lash format` to automatically fix indentation to the standard 2-space increments.", + example_bad: Some("- [ ] Parent\n - [ ] Child (3 spaces)\n - [ ] Another (4 spaces)"), + example_good: Some("- [ ] Parent\n - [ ] Child (2 spaces)\n - [ ] Another (2 spaces)"), + }), + _ => None, + } +} diff --git a/crates/lash-types/src/error_explanations/mod.rs b/crates/lash-types/src/error_explanations/mod.rs new file mode 100644 index 0000000..60b1778 --- /dev/null +++ b/crates/lash-types/src/error_explanations/mod.rs @@ -0,0 +1,217 @@ +//! Detailed error explanations for the `lash explain` command +//! +//! This module provides comprehensive documentation for each error code, +//! including: +//! - What the error means +//! - Why it occurs +//! - How to fix it +//! - Examples of the error and correct code +//! +//! Explanations are grouped into submodules by the surface that emits them: +//! parser codes, linter rule codes (syntax, semantic, cross-file), runtime +//! errors, and task-creation errors. Every code the linter can emit must have +//! an explanation here — `lash lint` tells users to run `lash explain `, +//! and that advice is only worth following if the code is known. + +mod creation; +mod crossfile; +mod legacy_lint; +mod parse; +mod runtime; +mod semantic; +mod syntax; + +/// Detailed explanation of an error code +#[derive(Debug, Clone)] +pub struct ErrorExplanation { + /// The error code being explained + pub code: &'static str, + + /// One-line summary of the error + pub summary: &'static str, + + /// Detailed description of what causes this error + pub description: &'static str, + + /// Why this error matters (what could go wrong if not fixed) + pub why_it_matters: &'static str, + + /// How to fix the error + pub how_to_fix: &'static str, + + /// Example of code that would produce this error + pub example_bad: Option<&'static str>, + + /// Example of correct code + pub example_good: Option<&'static str>, +} + +impl ErrorExplanation { + /// Format the explanation as markdown text + #[must_use] + pub fn to_markdown(&self) -> String { + let mut output = String::new(); + + output.push_str(&format!("# Error: {}\n\n", self.code)); + output.push_str(&format!("## {}\n\n", self.summary)); + output.push_str(&format!("**Description:** {}\n\n", self.description)); + output.push_str(&format!("**Why it matters:** {}\n\n", self.why_it_matters)); + output.push_str(&format!("**How to fix:** {}\n\n", self.how_to_fix)); + + if let Some(bad) = self.example_bad { + output.push_str("### Example (Incorrect)\n\n"); + output.push_str("```markdown\n"); + output.push_str(bad); + output.push_str("\n```\n\n"); + } + + if let Some(good) = self.example_good { + output.push_str("### Example (Correct)\n\n"); + output.push_str("```markdown\n"); + output.push_str(good); + output.push_str("\n```\n\n"); + } + + output + } +} + +/// Get the explanation for a specific error code +/// +/// Returns `None` if the error code is not recognized. +#[must_use] +pub fn explain_error(code: &str) -> Option { + parse::explain(code) + .or_else(|| legacy_lint::explain(code)) + .or_else(|| syntax::explain(code)) + .or_else(|| semantic::explain(code)) + .or_else(|| crossfile::explain(code)) + .or_else(|| runtime::explain(code)) + .or_else(|| creation::explain(code)) +} + +/// Get all available error codes that have explanations +#[must_use] +pub fn all_error_codes() -> Vec<&'static str> { + let mut codes = Vec::new(); + for group in [ + parse::CODES, + legacy_lint::CODES, + syntax::CODES, + semantic::CODES, + crossfile::CODES, + runtime::CODES, + creation::CODES, + ] { + codes.extend_from_slice(group); + } + codes +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::codes; + + #[test] + fn test_all_codes_have_explanations() { + for code in all_error_codes() { + let explanation = explain_error(code); + assert!( + explanation.is_some(), + "Error code {code} is listed but has no explanation" + ); + } + } + + #[test] + fn test_explanation_code_matches_lookup_key() { + for code in all_error_codes() { + let explanation = explain_error(code).unwrap(); + assert_eq!( + explanation.code, code, + "explain_error({code}) returned an explanation for {}", + explanation.code + ); + } + } + + #[test] + fn test_no_duplicate_codes() { + let codes = all_error_codes(); + let mut seen = std::collections::HashSet::new(); + for code in &codes { + assert!(seen.insert(*code), "Error code {code} is listed twice"); + } + } + + #[test] + fn test_explanation_markdown_format() { + let explanation = explain_error(codes::E_PARSE_INVALID_CHECKBOX).unwrap(); + let markdown = explanation.to_markdown(); + + assert!(markdown.contains("# Error:")); + assert!(markdown.contains(codes::E_PARSE_INVALID_CHECKBOX)); + assert!(markdown.contains("Description:")); + assert!(markdown.contains("Why it matters:")); + assert!(markdown.contains("How to fix:")); + } + + #[test] + fn test_unknown_code_returns_none() { + let explanation = explain_error("E_UNKNOWN_CODE"); + assert!(explanation.is_none()); + } + + #[test] + fn test_parse_errors_have_examples() { + let codes_with_examples = [ + codes::E_PARSE_INVALID_CHECKBOX, + codes::E_PARSE_INVALID_ANNOTATION, + codes::E_PARSE_INVALID_HEADER, + ]; + + for code in codes_with_examples { + let explanation = explain_error(code).unwrap(); + assert!( + explanation.example_bad.is_some(), + "{code} should have bad example" + ); + assert!( + explanation.example_good.is_some(), + "{code} should have good example" + ); + } + } + + // GitHub issue #58: `lash lint` emits W_INDEX_ORPHAN and E_LINK_NOT_FOUND, + // and the diagnostic footer points at `lash explain`. Before this, explain + // knew none of the linter's own codes. + #[test] + fn test_linter_rule_codes_are_explained() { + for code in [ + codes::W_INDEX_ORPHAN, + codes::E_LINK_NOT_FOUND, + codes::E_SYNTAX_CHECKBOX, + codes::E_SEM_DUPLICATE_ID, + codes::W_NOTE_TOO_LONG, + codes::I_SYNTAX_ORDER, + ] { + assert!( + explain_error(code).is_some(), + "linter code {code} must be explainable" + ); + } + } + + // The orphan explanation is the one place a user who hit the warning can + // learn that `.lashignore` exists. + #[test] + fn test_orphan_explanation_mentions_lashignore() { + let explanation = explain_error(codes::W_INDEX_ORPHAN).unwrap(); + assert!( + explanation.how_to_fix.contains(".lashignore"), + "W_INDEX_ORPHAN must point at .lashignore" + ); + } +} diff --git a/crates/lash-types/src/error_explanations/parse.rs b/crates/lash-types/src/error_explanations/parse.rs new file mode 100644 index 0000000..b39a059 --- /dev/null +++ b/crates/lash-types/src/error_explanations/parse.rs @@ -0,0 +1,80 @@ +//! Explanations for parser error codes (`E_PARSE_*`) + +use super::ErrorExplanation; +use crate::error::codes; + +/// Codes explained by this module +pub(super) const CODES: &[&str] = &[ + codes::E_PARSE, + codes::E_PARSE_INVALID_CHECKBOX, + codes::E_PARSE_INVALID_ANNOTATION, + codes::E_PARSE_INVALID_HEADER, + codes::E_PARSE_UNEXPECTED_DEPTH, + codes::E_PARSE_INVALID_DATE, +]; + +/// Look up a parse error explanation +pub(super) fn explain(code: &str) -> Option { + match code { + codes::E_PARSE => Some(ErrorExplanation { + code: codes::E_PARSE, + summary: "File could not be parsed", + description: "Lash could not read the file as a task file. The diagnostic message carries the specific reason and the line it stopped on; the `E_PARSE_*` codes describe the individual causes.", + why_it_matters: "A file that does not parse contributes nothing: none of its tasks are linted, indexed, listed or available as dependency targets. Everything downstream behaves as if the file were empty.", + how_to_fix: "Read the reason in the message and fix that line. The usual causes are an unrecognized checkbox marker, an annotation that is not `@key: value`, or indentation that is not a multiple of 2 spaces — `lash format` fixes all three.", + example_bad: None, + example_good: None, + }), + + codes::E_PARSE_INVALID_CHECKBOX => Some(ErrorExplanation { + code: codes::E_PARSE_INVALID_CHECKBOX, + summary: "Invalid checkbox syntax", + description: "The checkbox marker in your task is not valid. Lash only recognizes four checkbox states: [ ] for open, [x] for done, [-] for waived, and [!] for blocked.", + why_it_matters: "Invalid checkboxes prevent Lash from parsing your task files correctly, which means the task won't be indexed, tracked, or included in dependency resolution.", + how_to_fix: "Replace the invalid checkbox with one of the valid formats: [ ], [x], [-], or [!]. Run `lash format` to automatically fix checkbox formatting.", + example_bad: Some("- [*] Invalid checkbox\n- [v] Also invalid\n- [] Missing space"), + example_good: Some("- [ ] Open task\n- [x] Completed task\n- [-] Waived task\n- [!] Blocked task"), + }), + + codes::E_PARSE_INVALID_ANNOTATION => Some(ErrorExplanation { + code: codes::E_PARSE_INVALID_ANNOTATION, + summary: "Malformed annotation", + description: "An annotation in your task file doesn't follow the required @key: value format. Annotations must start with @, followed by the annotation name, a colon, and the value.", + why_it_matters: "Malformed annotations can't be parsed, so Lash won't be able to read metadata like task IDs, labels, owners, or dependencies.", + how_to_fix: "Ensure annotations follow the format: @key: value. There must be a space after the colon. Run `lash format` to normalize formatting.", + example_bad: Some("@id task-1\n@labels: frontend, backend\n@owner:Alice"), + example_good: Some("@id: task-1\n@labels: frontend, backend\n@owner: Alice"), + }), + + codes::E_PARSE_INVALID_HEADER => Some(ErrorExplanation { + code: codes::E_PARSE_INVALID_HEADER, + summary: "Invalid header format", + description: "A header in your file doesn't follow proper Markdown syntax. Headers must start with one or more # symbols followed by a space.", + why_it_matters: "Invalid headers break the document structure, making it impossible for Lash to organize tasks into sections.", + how_to_fix: "Add a space after the # symbols in your headers. For example, change '##Tasks' to '## Tasks'.", + example_bad: Some("##Tasks\n###Section 1"), + example_good: Some("## Tasks\n### Section 1"), + }), + + codes::E_PARSE_UNEXPECTED_DEPTH => Some(ErrorExplanation { + code: codes::E_PARSE_UNEXPECTED_DEPTH, + summary: "Unexpected indentation depth", + description: "A task or subtask has incorrect indentation. Each level of nesting should be indented by exactly 2 spaces.", + why_it_matters: "Incorrect indentation breaks the task hierarchy, which affects dependency resolution and task organization.", + how_to_fix: "Adjust the indentation to use exactly 2 spaces per nesting level. Run `lash format` to automatically fix indentation.", + example_bad: Some("- [ ] Parent\n - [ ] Child (3 spaces)\n- [ ] Another (wrong depth)"), + example_good: Some("- [ ] Parent\n - [ ] Child (2 spaces)\n - [ ] Grandchild (4 spaces)"), + }), + + codes::E_PARSE_INVALID_DATE => Some(ErrorExplanation { + code: codes::E_PARSE_INVALID_DATE, + summary: "Invalid date format", + description: "A date annotation doesn't use the required YYYY-MM-DD format.", + why_it_matters: "Invalid dates can't be parsed or compared, breaking features like task filtering by date and timeline calculations.", + how_to_fix: "Change the date to YYYY-MM-DD format. For example, '2024-01-15' for January 15, 2024.", + example_bad: Some("@created: 01/15/2024\n@created: Jan 15, 2024"), + example_good: Some("@created: 2024-01-15"), + }), + _ => None, + } +} diff --git a/crates/lash-types/src/error_explanations/runtime.rs b/crates/lash-types/src/error_explanations/runtime.rs new file mode 100644 index 0000000..369225b --- /dev/null +++ b/crates/lash-types/src/error_explanations/runtime.rs @@ -0,0 +1,227 @@ +//! Explanations for runtime error codes +//! +//! Covers dependency resolution (`E_DEP_*`), the `SQLite` index (`E_INDEX_*`), +//! queries (`E_QUERY_*`), configuration (`E_CONFIG_*`), I/O (`E_IO_*`) and +//! internal failures. + +use super::ErrorExplanation; +use crate::error::codes; + +/// Codes explained by this module +pub(super) const CODES: &[&str] = &[ + // Dependency errors + codes::E_DEP_NOT_FOUND, + codes::E_DEP_CYCLE, + codes::E_DEP_INVALID_REF, + // Index errors + codes::E_INDEX_CORRUPTED, + codes::E_INDEX_VERSION_MISMATCH, + codes::E_INDEX_OUT_OF_SYNC, + // Query errors + codes::E_QUERY_INVALID_SYNTAX, + codes::E_QUERY_NO_RESULTS, + // Config errors + codes::E_CONFIG_ROOT_NOT_FOUND, + codes::E_CONFIG_INVALID_VALUE, + codes::E_CONFIG_PARSE_ERROR, + codes::E_CONFIG_MISSING_INDEX, + // IO errors + codes::E_IO_FILE_NOT_FOUND, + codes::E_IO_READ_ERROR, + codes::E_IO_WRITE_ERROR, + codes::E_IO_PERMISSION_DENIED, + codes::E_IO_INVALID_PATH, + // Internal errors + codes::E_INTERNAL, +]; + +/// Look up a runtime error explanation +pub(super) fn explain(code: &str) -> Option { + match code { + // ===== Dependency Errors ===== + codes::E_DEP_NOT_FOUND => Some(ErrorExplanation { + code: codes::E_DEP_NOT_FOUND, + summary: "Dependency target not found", + description: "A task references a dependency that doesn't exist. The referenced file or task ID could not be found.", + why_it_matters: "Broken dependencies prevent accurate dependency graph construction and can cause tasks to appear blocked incorrectly.", + how_to_fix: "Check that the file path and task ID in the @depends-on annotation are correct. Verify the referenced task exists and has the correct @id.", + example_bad: Some("@depends-on: path/to/missing.md#task:nonexistent-id"), + example_good: Some("@depends-on: path/to/existing.md#task:valid-id"), + }), + + codes::E_DEP_CYCLE => Some(ErrorExplanation { + code: codes::E_DEP_CYCLE, + summary: "Circular dependency detected", + description: "A cycle exists in the dependency graph where task A depends on B, which depends on C, which depends back on A.", + why_it_matters: "Circular dependencies create logical impossibilities and prevent proper task ordering. No task in the cycle can ever be started.", + how_to_fix: "Break the cycle by removing one of the dependencies or restructuring the task relationships. The error message shows the cycle path.", + example_bad: Some("Task A @depends-on: B\nTask B @depends-on: C\nTask C @depends-on: A"), + example_good: Some("Task A @depends-on: B\nTask B @depends-on: C\nTask C has no dependencies"), + }), + + codes::E_DEP_INVALID_REF => Some(ErrorExplanation { + code: codes::E_DEP_INVALID_REF, + summary: "Invalid dependency reference format", + description: "The @depends-on annotation doesn't follow the required format: path/to/file.md#task:id", + why_it_matters: "Invalid reference format prevents Lash from resolving dependencies correctly.", + how_to_fix: "Use the format: path/to/file.md#task:id where the path is relative to the project root.", + example_bad: Some("@depends-on: file.md#id\n@depends-on: just-an-id"), + example_good: Some("@depends-on: tasks/setup.md#task:database-setup"), + }), + // ===== Index Errors ===== + codes::E_INDEX_CORRUPTED => Some(ErrorExplanation { + code: codes::E_INDEX_CORRUPTED, + summary: "Database corruption detected", + description: "The SQLite database has become corrupted or contains invalid data.", + why_it_matters: "A corrupted index prevents Lash from functioning correctly and may lead to data loss or incorrect results.", + how_to_fix: "Run `lash index --rebuild` to rebuild the database from scratch from your Markdown files.", + example_bad: None, + example_good: None, + }), + + codes::E_INDEX_VERSION_MISMATCH => Some(ErrorExplanation { + code: codes::E_INDEX_VERSION_MISMATCH, + summary: "Database schema version mismatch", + description: "The database was created with a different version of Lash and needs to be migrated to the current schema.", + why_it_matters: "Version mismatches can cause incorrect behavior or crashes when Lash tries to read incompatible database structures.", + how_to_fix: "Run `lash index --migrate` to update the database schema, or `lash index --rebuild` to rebuild from scratch.", + example_bad: None, + example_good: None, + }), + + codes::E_INDEX_OUT_OF_SYNC => Some(ErrorExplanation { + code: codes::E_INDEX_OUT_OF_SYNC, + summary: "Index is out of sync with files", + description: "The SQLite index doesn't match the current state of your Markdown files. Files have been modified since the last index update.", + why_it_matters: "An out-of-sync index means queries may return stale or incorrect data.", + how_to_fix: "Run `lash index` to update the index. Lash automatically detects changed files and updates only what's necessary.", + example_bad: None, + example_good: None, + }), + + // ===== Query Errors ===== + codes::E_QUERY_INVALID_SYNTAX => Some(ErrorExplanation { + code: codes::E_QUERY_INVALID_SYNTAX, + summary: "Invalid query syntax", + description: "The search query uses invalid syntax that can't be parsed.", + why_it_matters: "Invalid query syntax prevents the search from executing.", + how_to_fix: "Check the query syntax. Run `lash help search` for documentation on search query syntax.", + example_bad: None, + example_good: None, + }), + + codes::E_QUERY_NO_RESULTS => Some(ErrorExplanation { + code: codes::E_QUERY_NO_RESULTS, + summary: "No results found", + description: "The query executed successfully but returned no matching tasks.", + why_it_matters: "This may indicate the search criteria are too restrictive or the expected tasks don't exist.", + how_to_fix: "Try broadening your search criteria, removing some filters, or checking that the tasks you're looking for actually exist.", + example_bad: None, + example_good: None, + }), + + // ===== Config Errors ===== + codes::E_CONFIG_ROOT_NOT_FOUND => Some(ErrorExplanation { + code: codes::E_CONFIG_ROOT_NOT_FOUND, + summary: "Project root not found", + description: "Lash couldn't find a project root directory. It looks for lash.index.md, index.lash.md, or a .lash/ directory.", + why_it_matters: "Without a project root, Lash doesn't know where to look for task files or store the database.", + how_to_fix: "Run `lash init` to create a new project, or navigate to an existing Lash project directory. You can also use --root to specify the project root explicitly.", + example_bad: None, + example_good: None, + }), + + codes::E_CONFIG_INVALID_VALUE => Some(ErrorExplanation { + code: codes::E_CONFIG_INVALID_VALUE, + summary: "Invalid configuration value", + description: "A configuration value is invalid or out of acceptable range.", + why_it_matters: "Invalid configuration prevents Lash from starting or causes incorrect behavior.", + how_to_fix: "Check the configuration file for the invalid value and correct it according to the documentation.", + example_bad: None, + example_good: None, + }), + + codes::E_CONFIG_PARSE_ERROR => Some(ErrorExplanation { + code: codes::E_CONFIG_PARSE_ERROR, + summary: "Configuration parse error", + description: "The configuration file couldn't be parsed as valid TOML.", + why_it_matters: "A malformed configuration file prevents Lash from loading user preferences.", + how_to_fix: "Check the configuration file for syntax errors. Ensure it's valid TOML format.", + example_bad: None, + example_good: None, + }), + + codes::E_CONFIG_MISSING_INDEX => Some(ErrorExplanation { + code: codes::E_CONFIG_MISSING_INDEX, + summary: "Index file not found", + description: "The project root exists but doesn't contain an index file (lash.index.md or index.lash.md).", + why_it_matters: "The index file is the entry point for task navigation and defines the project structure.", + how_to_fix: "Create an index file at the project root: either lash.index.md or index.lash.md.", + example_bad: None, + example_good: None, + }), + + // ===== IO Errors ===== + codes::E_IO_FILE_NOT_FOUND => Some(ErrorExplanation { + code: codes::E_IO_FILE_NOT_FOUND, + summary: "File not found", + description: "The specified file doesn't exist.", + why_it_matters: "Lash can't operate on files that don't exist.", + how_to_fix: "Check that the file path is correct. If the file was moved or deleted, update any references to it.", + example_bad: None, + example_good: None, + }), + + codes::E_IO_READ_ERROR => Some(ErrorExplanation { + code: codes::E_IO_READ_ERROR, + summary: "Failed to read file", + description: "An I/O error occurred while trying to read a file.", + why_it_matters: "If Lash can't read files, it can't parse tasks or update the index.", + how_to_fix: "Check file permissions, disk space, and that the file isn't locked by another process.", + example_bad: None, + example_good: None, + }), + + codes::E_IO_WRITE_ERROR => Some(ErrorExplanation { + code: codes::E_IO_WRITE_ERROR, + summary: "Failed to write file", + description: "An I/O error occurred while trying to write to a file.", + why_it_matters: "Write errors prevent Lash from saving changes, formatting files, or creating new files.", + how_to_fix: "Check that you have write permissions, sufficient disk space, and that the file isn't read-only.", + example_bad: None, + example_good: None, + }), + + codes::E_IO_PERMISSION_DENIED => Some(ErrorExplanation { + code: codes::E_IO_PERMISSION_DENIED, + summary: "Permission denied", + description: "You don't have permission to access the specified file or directory.", + why_it_matters: "Permission errors prevent Lash from reading or writing files.", + how_to_fix: "Check file permissions and adjust them if needed, or run Lash with appropriate permissions.", + example_bad: None, + example_good: None, + }), + + codes::E_IO_INVALID_PATH => Some(ErrorExplanation { + code: codes::E_IO_INVALID_PATH, + summary: "Invalid path", + description: "The specified path is invalid or contains illegal characters.", + why_it_matters: "Invalid paths can't be used to access files.", + how_to_fix: "Check that the path is properly formatted and doesn't contain invalid characters for your operating system.", + example_bad: None, + example_good: None, + }), + + // ===== Internal Errors ===== + codes::E_INTERNAL => Some(ErrorExplanation { + code: codes::E_INTERNAL, + summary: "Internal error", + description: "An unexpected internal error occurred. This is likely a bug in Lash.", + why_it_matters: "Internal errors indicate bugs that should be reported to the Lash developers.", + how_to_fix: "Please report this error as a bug on the Lash issue tracker, including the full error message and steps to reproduce.", + example_bad: None, + example_good: None, + }), + _ => None, + } +} diff --git a/crates/lash-types/src/error_explanations/semantic.rs b/crates/lash-types/src/error_explanations/semantic.rs new file mode 100644 index 0000000..ad01ab7 --- /dev/null +++ b/crates/lash-types/src/error_explanations/semantic.rs @@ -0,0 +1,206 @@ +//! Explanations for the linter's semantic rules +//! +//! These are the codes `lash lint` emits once a file parses and its content is +//! interpreted: `E_SEM_*`, `W_SEM_*`, `I_SEM_*`, and the contextual-note rules +//! (`E_NOTE_*`, `W_NOTE_*`). + +use super::ErrorExplanation; +use crate::error::codes; + +/// Codes explained by this module +pub(super) const CODES: &[&str] = &[ + codes::E_SEM_DUPLICATE_ID, + codes::E_SEM_EMPTY_TITLE, + codes::E_SEM_INVALID_DATE, + codes::E_SEM_INVALID_DOC, + codes::E_SEM_INVALID_ESTIMATE, + codes::E_SEM_INVALID_LABEL, + codes::E_SEM_DESC_TOO_LONG, + codes::W_SEM_DESC_TOO_LONG, + codes::W_SEM_DOC_FRAGMENT, + codes::W_SEM_OWNER_FORMAT, + codes::W_SEM_STATUS_INCONSISTENT, + codes::I_SEM_AUTO_WAIVE, + codes::E_NOTE_INVALID_INDENT, + codes::E_NOTE_HAS_CHILDREN, + codes::E_NOTE_EXCESSIVE_LENGTH, + codes::W_NOTE_TOO_LONG, + codes::W_NOTE_AFTER_CHILD_TASKS, +]; + +/// Look up a semantic rule explanation +pub(super) fn explain(code: &str) -> Option { + match code { + codes::E_SEM_DUPLICATE_ID => Some(ErrorExplanation { + code: codes::E_SEM_DUPLICATE_ID, + summary: "Two tasks in the same file share an ID", + description: "Task IDs must be unique within a file. Either two tasks declare the same `@id`, or two titles derive the same implicit ID (IDs are derived from titles when `@id` is absent).", + why_it_matters: "An ID is how everything else points at a task: `@depends-on`, `lash show`, `lash complete`. When two tasks answer to one ID, references resolve to whichever comes first and the other task becomes unreachable.", + how_to_fix: "Give one of the tasks an explicit, distinct `@id`, or reword its title so the derived ID differs.", + example_bad: Some("- [ ] Set up\n @id: setup\n- [ ] Set up\n @id: setup"), + example_good: Some("- [ ] Set up database\n @id: setup-database\n- [ ] Set up server\n @id: setup-server"), + }), + + codes::E_SEM_EMPTY_TITLE => Some(ErrorExplanation { + code: codes::E_SEM_EMPTY_TITLE, + summary: "Task has an empty title", + description: "A checkbox line has no text after the marker, so the task has nothing to identify it.", + why_it_matters: "A task with no title has no derived ID and no display text — it is invisible in listings and impossible to reference.", + how_to_fix: "Write a title after the checkbox, or delete the line if it was left over from editing.", + example_bad: Some("- [ ]\n- [x] "), + example_good: Some("- [ ] Write the migration guide"), + }), + + codes::E_SEM_INVALID_DATE => Some(ErrorExplanation { + code: codes::E_SEM_INVALID_DATE, + summary: "Date annotation is not a valid `YYYY-MM-DD` date", + description: "A date annotation such as `@created:` is either not in `YYYY-MM-DD` form or names a day that does not exist (for example 2024-02-30).", + why_it_matters: "Dates that do not parse cannot be sorted or compared, so the task drops out of any date-ordered view.", + how_to_fix: "Write the date as `YYYY-MM-DD` with a real calendar day.", + example_bad: Some("@created: 01/15/2024\n@created: 2024-02-30"), + example_good: Some("@created: 2024-01-15"), + }), + + codes::E_SEM_INVALID_DOC => Some(ErrorExplanation { + code: codes::E_SEM_INVALID_DOC, + summary: "`@doc:` annotation points at a file that does not exist", + description: "The path in a `@doc:` annotation does not resolve to a file inside the project. Paths are resolved relative to the file containing the annotation.", + why_it_matters: "A `@doc:` link is a promise that the referenced document exists. A broken one sends readers and agents to a file that is not there — usually the sign of a moved or renamed doc.", + how_to_fix: "Correct the path, or update it to the document's new location. Use `lash check-links` to find every broken reference at once.", + example_bad: Some("- [ ] Implement auth\n @doc: ../docs/moved-away.md"), + example_good: Some("- [ ] Implement auth\n @doc: ../docs/auth.md#token-refresh"), + }), + + codes::E_SEM_INVALID_ESTIMATE => Some(ErrorExplanation { + code: codes::E_SEM_INVALID_ESTIMATE, + summary: "`@estimate:` value is not a number followed by a unit", + description: "Estimates are a number followed by a unit character: `h` (hours), `d` (days), `w` (weeks), `m` (months) or `y` (years).", + why_it_matters: "Estimates that do not parse cannot be summed or compared, so the task contributes nothing to planning views.", + how_to_fix: "Write the estimate as a number plus a unit, such as `4h`, `2d` or `1w`.", + example_bad: Some("@estimate: two days\n@estimate: 4 hours"), + example_good: Some("@estimate: 4h\n@estimate: 2d"), + }), + + codes::E_SEM_INVALID_LABEL => Some(ErrorExplanation { + code: codes::E_SEM_INVALID_LABEL, + summary: "Label is not lowercase alphanumeric with hyphens or underscores", + description: "Labels must start with a letter or digit and contain only lowercase letters, digits, hyphens and underscores — whether written inline as `#label` or in a `@labels:` list.", + why_it_matters: "Labels are the main filtering axis (`lash list --label backend`). Case and punctuation variants split one concept into several labels that never match each other.", + how_to_fix: "Lowercase the label and replace spaces and punctuation with hyphens. `lash lint --fix` normalizes labels automatically.", + example_bad: Some("@labels: Front-End, back end!\n- [ ] Task #High-Priority"), + example_good: Some("@labels: front-end, back-end\n- [ ] Task #high-priority"), + }), + + codes::E_SEM_DESC_TOO_LONG => Some(ErrorExplanation { + code: codes::E_SEM_DESC_TOO_LONG, + summary: "Description exceeds the hard length limit", + description: "The file's `## Description` section is past the hard limit — twice the recommended length, so 2000 characters unless `linter.description_max_length` in `.lash/config.toml` says otherwise. Between the recommended and hard limits the linter warns with W_SEM_DESC_TOO_LONG; past the hard limit it errors.", + why_it_matters: "Descriptions are included verbatim in agent prompts and file listings. A very long one crowds out the tasks it was meant to introduce and burns context budget on every run.", + how_to_fix: "Trim the description to a short orientation paragraph and move the detail into a document referenced with `@doc:`.", + example_bad: None, + example_good: None, + }), + + codes::W_SEM_DESC_TOO_LONG => Some(ErrorExplanation { + code: codes::W_SEM_DESC_TOO_LONG, + summary: "Description exceeds the recommended length", + description: "The file's `## Description` section is past the recommended length (1000 characters by default, set by `linter.description_max_length` in `.lash/config.toml`). This is a warning; at twice that length it becomes E_SEM_DESC_TOO_LONG.", + why_it_matters: "Long descriptions are read on every listing and included in agent prompts, so length here is paid repeatedly.", + how_to_fix: "Shorten the description, or move the detail into a document referenced with `@doc:`.", + example_bad: None, + example_good: None, + }), + + codes::W_SEM_DOC_FRAGMENT => Some(ErrorExplanation { + code: codes::W_SEM_DOC_FRAGMENT, + summary: "@doc: fragment does not match any heading", + description: "An @doc annotation references a #fragment that does not exist in the target document. Lash matches fragments against headings using case- and punctuation-insensitive normalization: both the fragment and each heading are lowercased, '-' is treated as whitespace, every non-alphanumeric/non-whitespace character (including '<', '>', '/', '.', '_', '(', ')', and backticks) is stripped *without* introducing a hyphen boundary, and runs of whitespace are collapsed. Two strings match when they reduce to the same canonical form.", + why_it_matters: "Broken @doc: fragments mean readers (humans and agents) following the link cannot land on the intended section. The lint catches them so they fail loudly instead of silently 404-ing in a renderer that ignores anchors.", + how_to_fix: "Open the target document, find the heading you want, and write the fragment so it normalizes to the same canonical form. The warning's help text lists existing headings in the target — pick one. Convention: lowercase the heading, replace spaces with '-', and drop punctuation entirely (do not turn '/' or '.' into a hyphen).", + example_bad: Some("# Heading: Pack manifest (`/SKILL.md`)\n@doc: ../docs/skills.md#pack-manifest-pack-skill-md\n# (`<` `>` `/` are stripped without producing a boundary, so this slug is wrong)"), + example_good: Some("# Heading: Pack manifest (`/SKILL.md`)\n@doc: ../docs/skills.md#pack-manifest-packskillmd\n\n# Heading: Validation rules (must pass at index time)\n@doc: ../docs/skills.md#validation-rules-must-pass-at-index-time"), + }), + + codes::W_SEM_OWNER_FORMAT => Some(ErrorExplanation { + code: codes::W_SEM_OWNER_FORMAT, + summary: "`@owner:` value is empty or implausibly long", + description: "The owner annotation is blank, or longer than 100 characters — long enough that it is probably a sentence that landed in the wrong annotation.", + why_it_matters: "Owner is a grouping key (`lash list --owner alice`). Blank or prose-length owners produce groups nobody can filter on.", + how_to_fix: "Use a short handle or name, or remove the annotation if the task is unassigned. Put explanatory text in `@agent-note:` or a contextual note instead.", + example_bad: Some("@owner:\n@owner: alice, but only after the infra migration lands and she is back from leave"), + example_good: Some("@owner: alice"), + }), + + codes::W_SEM_STATUS_INCONSISTENT => Some(ErrorExplanation { + code: codes::W_SEM_STATUS_INCONSISTENT, + summary: "Parent is marked done while a child is still open", + description: "A parent task is `[x]` but at least one of its children is not done or waived. In Lash a parent is complete only when its children are.", + why_it_matters: "The parent's status is what rolls up into progress counts. A parent that claims completion over unfinished children makes those counts wrong, and the open children stop appearing as work.", + how_to_fix: "Close or waive the remaining children, or reopen the parent. `lash lint --fix` can reconcile the statuses.", + example_bad: Some("- [x] Ship feature\n - [ ] Write tests"), + example_good: Some("- [x] Ship feature\n - [x] Write tests\n\n# or\n\n- [ ] Ship feature\n - [ ] Write tests"), + }), + + codes::I_SEM_AUTO_WAIVE => Some(ErrorExplanation { + code: codes::I_SEM_AUTO_WAIVE, + summary: "Children of a waived parent can be auto-waived", + description: "A parent task is waived `[-]` but some children are still open. Waiving a parent means the whole branch does not apply, so the children can be waived too. This is informational and never fails a lint run.", + why_it_matters: "Children left open under a waived parent keep showing up as outstanding work that nobody intends to do.", + how_to_fix: "Run `lash format` (or `lash lint --fix`) to waive the children, or waive them by hand. Ignore the suggestion if the children really are still in play — in which case the parent probably should not be waived.", + example_bad: Some("- [-] Legacy migration (not applicable)\n - [ ] Migrate table A\n - [ ] Migrate table B"), + example_good: Some("- [-] Legacy migration (not applicable)\n - [-] Migrate table A\n - [-] Migrate table B"), + }), + + codes::E_NOTE_INVALID_INDENT => Some(ErrorExplanation { + code: codes::E_NOTE_INVALID_INDENT, + summary: "Contextual note is not indented 2 spaces past its task", + description: "A contextual note is a plain bullet (no checkbox) attached to a task. It must be indented exactly 2 spaces deeper than the task line it belongs to.", + why_it_matters: "Indentation is what binds a note to its task. At the wrong depth the note attaches to a different task, or to none at all, and stops travelling with the work it describes.", + how_to_fix: "Indent the note 2 spaces past its task line. `lash format` fixes note indentation.", + example_bad: Some("- [ ] Implement auth\n- Must support SSO"), + example_good: Some("- [ ] Implement auth\n - Must support SSO"), + }), + + codes::E_NOTE_HAS_CHILDREN => Some(ErrorExplanation { + code: codes::E_NOTE_HAS_CHILDREN, + summary: "Contextual note has nested children", + description: "A contextual note is a leaf: it cannot have bullets or tasks nested under it.", + why_it_matters: "Nesting under a note is ambiguous — the nested lines belong either to the note's task or to nothing. Keeping notes flat keeps the tree unambiguous.", + how_to_fix: "Flatten the note into a single bullet, or promote the nested items to sibling notes or real subtasks of the parent task.", + example_bad: Some("- [ ] Implement auth\n - Must support SSO\n - and SAML"), + example_good: Some("- [ ] Implement auth\n - Must support SSO\n - Must support SAML"), + }), + + codes::E_NOTE_EXCESSIVE_LENGTH => Some(ErrorExplanation { + code: codes::E_NOTE_EXCESSIVE_LENGTH, + summary: "Contextual note exceeds the hard length limit", + description: "A contextual note is longer than 500 characters. Between 200 and 500 characters the linter warns with W_NOTE_TOO_LONG; past 500 it errors.", + why_it_matters: "Notes are inlined next to their task everywhere the task is shown, including agent prompts. A note this long is a document living in a bullet.", + how_to_fix: "Cut the note to a sentence or two and move the rest into a document referenced with `@doc:`, or into the file's `## Description`.", + example_bad: None, + example_good: None, + }), + + codes::W_NOTE_TOO_LONG => Some(ErrorExplanation { + code: codes::W_NOTE_TOO_LONG, + summary: "Contextual note exceeds the recommended length", + description: "A contextual note is longer than the recommended 200 characters. Past 500 characters it becomes E_NOTE_EXCESSIVE_LENGTH.", + why_it_matters: "Notes are shown inline with their task, so a long one pushes the task list off the screen and into the scrollback.", + how_to_fix: "Shorten the note, split it into two notes, or move the detail into a `@doc:` reference.", + example_bad: None, + example_good: None, + }), + + codes::W_NOTE_AFTER_CHILD_TASKS => Some(ErrorExplanation { + code: codes::W_NOTE_AFTER_CHILD_TASKS, + summary: "Contextual note appears after child tasks", + description: "A task's contextual notes come before its child tasks. This note sits after them.", + why_it_matters: "Notes read as context for the work that follows. Placed after the children, a note looks like it belongs to the last child rather than to the parent.", + how_to_fix: "Move the note above the first child task. `lash format` reorders notes for you.", + example_bad: Some("- [ ] Implement auth\n - [ ] Add login form\n - Must support SSO"), + example_good: Some("- [ ] Implement auth\n - Must support SSO\n - [ ] Add login form"), + }), + + _ => None, + } +} diff --git a/crates/lash-types/src/error_explanations/syntax.rs b/crates/lash-types/src/error_explanations/syntax.rs new file mode 100644 index 0000000..b205b6a --- /dev/null +++ b/crates/lash-types/src/error_explanations/syntax.rs @@ -0,0 +1,107 @@ +//! Explanations for the linter's syntax rules +//! +//! These are the codes `lash lint` emits for structural problems it can see in +//! a single file without interpreting the content: `E_SYNTAX_*`, +//! `W_SYNTAX_HEADER` and `I_SYNTAX_ORDER`. + +use super::ErrorExplanation; +use crate::error::codes; + +/// Codes explained by this module +pub(super) const CODES: &[&str] = &[ + codes::E_SYNTAX_CHECKBOX, + codes::E_SYNTAX_INDENT, + codes::E_SYNTAX_DEPTH, + codes::E_SYNTAX_ANNOTATION, + codes::E_SYNTAX_UNKNOWN_KEY, + codes::E_SYNTAX_DUPLICATE_DESCRIPTION, + codes::W_SYNTAX_HEADER, + codes::I_SYNTAX_ORDER, +]; + +/// Look up a syntax rule explanation +pub(super) fn explain(code: &str) -> Option { + match code { + codes::E_SYNTAX_CHECKBOX => Some(ErrorExplanation { + code: codes::E_SYNTAX_CHECKBOX, + summary: "Checkbox marker is not one of the four valid states", + description: "A task line uses a checkbox marker Lash does not recognize. The only valid markers are `[ ]` (open), `[x]` (done), `[-]` (waived) and `[!]` (blocked), written as `- [x] Title` with a single space on either side of the marker.", + why_it_matters: "A line whose checkbox Lash cannot read is not a task: it will not be indexed, listed, searched, or considered when resolving dependencies. The text stays in the file and silently drops out of every query.", + how_to_fix: "Replace the marker with one of `[ ]`, `[x]`, `[-]` or `[!]`. `lash format` rewrites near-miss markers (such as `[X]`) automatically.", + example_bad: Some("- [*] Invalid marker\n- [] Missing space\n-[ ] Missing space after the dash"), + example_good: Some("- [ ] Open task\n- [x] Completed task\n- [-] Waived task\n- [!] Blocked task"), + }), + + codes::E_SYNTAX_INDENT => Some(ErrorExplanation { + code: codes::E_SYNTAX_INDENT, + summary: "Checkbox indentation is not a multiple of 2 spaces", + description: "Every nesting level in a Lash task list is exactly 2 spaces. A checkbox line indented by 3 spaces, or with a tab, does not land on a level.", + why_it_matters: "Indentation is the only thing that encodes parent/child structure. An off-by-one indent silently reparents a task, which changes which parent it blocks and where it shows up in the tree.", + how_to_fix: "Indent by 2 spaces per level and use spaces, not tabs. `lash format` normalizes indentation for you.", + example_bad: Some("- [ ] Parent\n - [ ] Child (3 spaces)\n\t- [ ] Child (tab)"), + example_good: Some("- [ ] Parent\n - [ ] Child\n - [ ] Grandchild"), + }), + + codes::E_SYNTAX_DEPTH => Some(ErrorExplanation { + code: codes::E_SYNTAX_DEPTH, + summary: "Task nesting exceeds the configured depth limit", + description: "A task is nested deeper than the project's `max_depth` allows (3 levels by default, settable to 2-5 via `max_depth` in `.lash/config.toml`).", + why_it_matters: "Deep hierarchies are hard to read in the terminal and usually mean one file is carrying work that belongs in its own file. The limit keeps files scannable.", + how_to_fix: "Flatten the hierarchy, move the deep branch into its own task file and link it with `@depends-on`, or raise `max_depth` in `.lash/config.toml` if your project genuinely needs more levels.", + example_bad: Some("- [ ] Level 1\n - [ ] Level 2\n - [ ] Level 3\n - [ ] Level 4 (beyond the default limit)"), + example_good: Some("- [ ] Level 1\n - [ ] Level 2\n - [ ] Level 3\n @depends-on: tasks/details.md#task:level-4-work"), + }), + + codes::E_SYNTAX_ANNOTATION => Some(ErrorExplanation { + code: codes::E_SYNTAX_ANNOTATION, + summary: "Annotation line does not match `@key: value`", + description: "A line starting with `@` is not a well-formed annotation. Annotations are `@key: value` — the key immediately after the `@`, then a colon, then a space, then the value.", + why_it_matters: "A malformed annotation carries no metadata: the ID, labels, owner or dependency it was meant to declare simply do not exist as far as Lash is concerned.", + how_to_fix: "Write the annotation as `@key: value`, one per line, indented to the same level as the task it belongs to. `lash format` fixes spacing around the colon.", + example_bad: Some("@id task-1\n@owner:Alice\n@ labels: backend"), + example_good: Some("@id: task-1\n@owner: Alice\n@labels: backend"), + }), + + codes::E_SYNTAX_UNKNOWN_KEY => Some(ErrorExplanation { + code: codes::E_SYNTAX_UNKNOWN_KEY, + summary: "Annotation key is neither built-in nor explicitly allowed", + description: "The annotation key is not one Lash knows (`@id`, `@labels`, `@status`, `@owner`, `@estimate`, `@created`, `@depends-on`, `@doc`, `@agent-note`) and it is not listed in `custom_annotation_keys` in `.lash/config.toml`.", + why_it_matters: "Unknown keys are almost always typos, and a typo means the metadata is silently absent. Requiring custom keys to be declared keeps that failure loud instead of quiet.", + how_to_fix: "Fix the typo — the diagnostic suggests the closest built-in key — or add the key to `custom_annotation_keys` in `.lash/config.toml` if it is intentional.", + example_bad: Some("@labls: backend\n@priority: high # not built-in and not declared"), + example_good: Some("@labels: backend\n\n# .lash/config.toml\n# custom_annotation_keys = [\"priority\"]"), + }), + + codes::E_SYNTAX_DUPLICATE_DESCRIPTION => Some(ErrorExplanation { + code: codes::E_SYNTAX_DUPLICATE_DESCRIPTION, + summary: "File has more than one `## Description` section", + description: "A task file may contain at most one `## Description` section. The diagnostic lists the line of every duplicate it found.", + why_it_matters: "With two description sections there is no defined answer to \"what is this file's description\" — Lash indexes one of them, and the other silently disappears from `lash list --show-descriptions` and agent prompts.", + how_to_fix: "Merge the sections into a single `## Description` block and delete the extras.", + example_bad: Some("# Tasks\n\n## Description\n\nFirst.\n\n## Description\n\nSecond."), + example_good: Some("# Tasks\n\n## Description\n\nFirst. Second."), + }), + + codes::W_SYNTAX_HEADER => Some(ErrorExplanation { + code: codes::W_SYNTAX_HEADER, + summary: "File is missing its H1 title or `## Tasks` section", + description: "Every Lash task file starts with a single `# Title` heading and holds its checkboxes under a `## Tasks` heading. This file is missing one of them.", + why_it_matters: "The H1 is the file's display name in listings and the TUI, and `## Tasks` marks where the task list begins. Without them the file renders as untitled and its structure is ambiguous to both readers and agents.", + how_to_fix: "Add the missing heading. `lash init` and `lash add --file-title` generate the standard skeleton.", + example_bad: Some("@id: tasks\n\n- [ ] A task with no headings above it"), + example_good: Some("# Backend Tasks\n\n@id: backend\n\n## Tasks\n\n- [ ] A task"), + }), + + codes::I_SYNTAX_ORDER => Some(ErrorExplanation { + code: codes::I_SYNTAX_ORDER, + summary: "Annotations are not in the conventional order", + description: "Annotations on a task read most predictably in a consistent order. This is an informational suggestion, never an error, and it never blocks a lint run.", + why_it_matters: "Consistent ordering makes diffs smaller and lets readers find `@id` or `@depends-on` in the same place in every file.", + how_to_fix: "Run `lash format`, which orders annotations for you, or reorder them by hand. Filter the suggestion out with `lash lint --min-severity warning` if you do not want to see it.", + example_bad: Some("- [ ] Task\n @owner: alice\n @id: task-1\n @labels: backend"), + example_good: Some("- [ ] Task\n @id: task-1\n @labels: backend\n @owner: alice"), + }), + + _ => None, + } +} diff --git a/devlog.md b/devlog.md index ddffbb4..bc91650 100644 --- a/devlog.md +++ b/devlog.md @@ -2724,3 +2724,56 @@ printed rather than left implicit. The deeper fix available to any project is `@id:`. A pinned ID is the only one a future derivation change cannot move, and the docs now say so in the three places someone would be reading when they care. + +## `.lashignore` and the linter's own codes were unreachable (#58, 2026-08-13) + +Two discoverability failures reported from one session of real use, and both +end at the same place: the user is looking at a diagnostic and the thing that +resolves it is documented somewhere they are not. + +`.lashignore` already worked. It is honoured by both walkers, it has a test, +and this repo uses one. It appeared in `docs/agent-workflows.md`, `devlog.md` +and a passing mention in `lash.index.md` — none of which is where someone +stands when a `content/` directory of prose starts reporting `W_INDEX_ORPHAN` +once per file and once more with every file added. `lash --help`, `lash lint +--help` and `lash config list` said nothing, so the reasonable conclusion was +that no ignore mechanism existed. + +It is now named in the warning itself. The per-diagnostic `help` field only +surfaces under `-v`, which is why the pointer is in the message text: the +warning is the only surface guaranteed to be read, and one of these per file is +exactly the situation where the escape hatch has to be on screen. `lash lint +--help` and the top-level `--help` describe file discovery, and the README and +user guide each carry a short section. + +The second half is what made the first expensive. `lash explain`, which the +output points at, knew none of the codes `lash lint` emits. `--list` showed 46 +codes, of which one was a warning; `lash explain W_INDEX_ORPHAN` and `lash +explain E_LINK_NOT_FOUND` both answered "Unknown error code". Following the +advice in the error output landed on a dead end at the moment of confusion. + +The linter's codes and the explanation table were simply never connected: the +`E_LINT_*` entries in `error_explanations` predate the per-rule `E_SYNTAX_*` / +`E_SEM_*` / `E_LINK_*` codes the rules actually emit, and nothing forced the +two sets to agree. All 29 missing codes now have entries, including the two +that only appear at a different severity (`E_SEM_DESC_TOO_LONG`, +`E_NOTE_EXCESSIVE_LENGTH`) and are therefore invisible to a rule's `code()`. +A test in the rule registry walks `register_default_rules` and fails if any +rule's code has no explanation, so a new rule cannot reintroduce the gap. + +`explain --list` was dropping codes silently. Its categoriser was an if-else +chain over nine prefixes with no fallback, so anything unmatched — every `W_` +and `I_` code — was collected into no bucket and never printed. It is now a +prefix table with an explicit "Other Codes" bucket: a code with a new prefix +shows up in the wrong-looking category instead of vanishing, which is the +failure mode worth having. Order matters in the table, since +`E_INDEX_FILE_MISSING` is a cross-file lint rule and would otherwise be claimed +by the `E_INDEX` database prefix. + +`error_explanations.rs` was split into a module directory along the way — it +was already past the repo's 500-line guideline and this change nearly doubled +it. The split is by emitting surface (parse, syntax, semantic, cross-file, +runtime, creation), which is also the grouping `--list` prints. + +Finally, the lint summary now closes the loop it opens: it names one of the +codes it just reported and the `lash explain` invocation for it. diff --git a/docs/error-codes.md b/docs/error-codes.md index c07d856..8011df2 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -2,8 +2,99 @@ This document describes all error codes used by Lash, organized by category. +Every code listed here is also available from the CLI: + +```bash +lash explain W_INDEX_ORPHAN # one code, in detail +lash explain --list # every code, grouped by category +``` + +## Linter Rule Codes + +These are the codes `lash lint` emits. Run `lash explain ` for the full +entry — what the rule checks, why it matters, and how to fix it — with examples. + +### Syntax rules + +| Code | Meaning | +| --- | --- | +| `E_SYNTAX_CHECKBOX` | Checkbox marker is not one of `[ ]`, `[x]`, `[-]`, `[!]` | +| `E_SYNTAX_INDENT` | Checkbox indentation is not a multiple of 2 spaces | +| `E_SYNTAX_DEPTH` | Task nesting exceeds the configured `max_depth` | +| `E_SYNTAX_ANNOTATION` | Annotation line does not match `@key: value` | +| `E_SYNTAX_UNKNOWN_KEY` | Annotation key is neither built-in nor declared in `custom_annotation_keys` | +| `E_SYNTAX_DUPLICATE_DESCRIPTION` | File has more than one `## Description` section | +| `W_SYNTAX_HEADER` | File is missing its H1 title or `## Tasks` section | +| `I_SYNTAX_ORDER` | Annotations are not in the conventional order | + +### Semantic rules + +| Code | Meaning | +| --- | --- | +| `E_SEM_DUPLICATE_ID` | Two tasks in the same file share an ID | +| `E_SEM_EMPTY_TITLE` | Task has an empty title | +| `E_SEM_INVALID_DATE` | Date annotation is not a valid `YYYY-MM-DD` date | +| `E_SEM_INVALID_DOC` | `@doc:` points at a file that does not exist | +| `E_SEM_INVALID_ESTIMATE` | `@estimate:` is not a number plus a unit (`h`/`d`/`w`/`m`/`y`) | +| `E_SEM_INVALID_LABEL` | Label is not lowercase alphanumeric with hyphens/underscores | +| `E_SEM_DESC_TOO_LONG` | Description exceeds the hard length limit | +| `W_SEM_DESC_TOO_LONG` | Description exceeds the recommended length | +| `W_SEM_DOC_FRAGMENT` | `@doc:` fragment matches no heading in the target document | +| `W_SEM_OWNER_FORMAT` | `@owner:` is empty or implausibly long | +| `W_SEM_STATUS_INCONSISTENT` | Parent is marked done while a child is still open | +| `I_SEM_AUTO_WAIVE` | Children of a waived parent can be auto-waived | +| `E_NOTE_INVALID_INDENT` | Contextual note is not indented 2 spaces past its task | +| `E_NOTE_HAS_CHILDREN` | Contextual note has nested children | +| `E_NOTE_EXCESSIVE_LENGTH` | Contextual note exceeds the hard length limit (500 chars) | +| `W_NOTE_TOO_LONG` | Contextual note exceeds the recommended length (200 chars) | +| `W_NOTE_AFTER_CHILD_TASKS` | Contextual note appears after child tasks | + +### Cross-file rules + +| Code | Meaning | +| --- | --- | +| `E_LINK_NOT_FOUND` | `@depends-on:` target file or task does not exist | +| `E_LINK_CYCLE` | Dependency references form a cycle | +| `E_LINK_INVALID_PATH` | Dependency path is malformed or escapes the project root | +| `E_INDEX_FILE_MISSING` | Root index references a file that does not exist | +| `W_INDEX_ORPHAN` | Markdown file is not referenced in the root index | + +#### W_INDEX_ORPHAN and `.lashignore` + +`W_INDEX_ORPHAN` fires once per unreferenced `.md` file, so a directory of +non-task Markdown produces a warning per file and one more with every file +added. Two ways out: + +- The file **is** a task file → add a link to it in `lash.index.md`. +- The file **is not** a task file → add it to `.lashignore` at the project root. + `.lashignore` uses `.gitignore` syntax (one pattern per line, trailing `/` for + a directory) and removes the path from file discovery for every command that + walks the project. + +``` +# .lashignore +content/ +vendor/ +NOTES.md +``` + +Common documentation filenames (`README.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, +`devlog.md`, …) and the `docs/`, `doc/`, `documentation/` and `.github/` +directories are exempt from this warning without any configuration. + +--- + ## Parse Errors (E_PARSE_*) +### E_PARSE + +**Description:** A file could not be parsed; the diagnostic message carries the +specific reason and the line it stopped on. The codes below describe the +individual causes. + +**How to fix:** Fix the line named in the message. `lash format` resolves the +common causes (checkbox markers, annotation spacing, indentation). + ### E_PARSE_INVALID_CHECKBOX **Description:** Invalid checkbox syntax in task list @@ -72,6 +163,10 @@ This document describes all error codes used by Lash, organized by category. ## Lint Errors (E_LINT_*) +These are the older generic lint codes. `lash lint` now reports the per-rule +codes listed under [Linter Rule Codes](#linter-rule-codes); the `E_LINT_*` codes +remain valid input to `lash explain` so older output and scripts keep resolving. + ### E_LINT_DEPTH_EXCEEDED **Description:** Task nesting exceeds maximum allowed depth diff --git a/docs/user-guide.md b/docs/user-guide.md index f815c22..c0a7936 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -141,6 +141,36 @@ my-project/ - Reference task files from your index - Add `.lash/lash.db` to `.gitignore` +### Excluding Files with `.lashignore` + +Every command that walks the project — `lash lint`, `lash format`, `lash index`, +`lash check-index`, `lash check-links` — visits every `.md` file under the +project root. Files excluded by `.gitignore` are skipped automatically, and so +are files matched by a `.lashignore` at the project root. + +`.lashignore` uses `.gitignore` syntax: one pattern per line, a trailing `/` for +a directory. + +``` +# .lashignore +content/ # prose that ships with the site, never tasks +vendor/ +NOTES.md +``` + +Reach for it when a directory of Markdown is not task files. Without it, every +such file is reported once per lint run: + +``` +warning[W_INDEX_ORPHAN]: File 'content/a-post.md' is not referenced in the root index + (add it to .lashignore if it is not a task file) +``` + +Common documentation names (`README.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, +`devlog.md`, …) and the `docs/`, `doc/`, `documentation/` and `.github/` +directories are already exempt from that warning — you only need `.lashignore` +for everything else. + ### Try the Playground Want to explore Lash features without setting up your own project? Use the playground: @@ -514,6 +544,19 @@ lash lint --fix --diff - Duplicate IDs within files - Contextual note placement - Indentation consistency +- Cross-file links and root-index coverage + +**Which files it reads:** every `.md` file under the project root, minus +anything excluded by `.gitignore` or `.lashignore`. See +[Excluding Files with `.lashignore`](#excluding-files-with-lashignore). + +**Understanding a diagnostic:** every code the linter reports is explained by +`lash explain`: + +```bash +lash explain W_INDEX_ORPHAN # what the code means, why, and how to fix it +lash explain --list # every code, grouped by category +``` #### `lash format` diff --git a/lash.index.md b/lash.index.md index fcf98ed..0483495 100644 --- a/lash.index.md +++ b/lash.index.md @@ -421,3 +421,22 @@ filed in the flawd repo under `tasks/tasks.fail-fast-degradation.md`. - A lint rule was suggested on #48 for hand-edited files. Still not worth it: a misattributed body is syntactically indistinguishable from a correct one, so there is nothing for the linter to check against + +### Post-0.4.0 (dogfooding, 2026-08-13) + +- [x] `.lashignore` was undiscoverable, and `lash explain` did not know the linter's codes (#58) #cli #docs + - `.lashignore` already worked and was reachable from nothing a user reads + when they hit `W_INDEX_ORPHAN` — not the warning, not `lash lint --help`, + not `lash --help`. A directory of non-task Markdown produced one warning + per file with no visible way out, so the reasonable conclusion was that no + ignore mechanism existed + - The warning now names `.lashignore` in its message text, not just its + `help` field, since help only surfaces under `-v`. Both `--help` surfaces + describe file discovery; README and the user guide each gained a section + - Second half: `lash explain` knew 46 codes, one of them a warning, and none + of the per-rule codes `lash lint` emits. Following the output's own advice + dead-ended. All 29 missing codes now have entries, and a registry test + fails if a new rule ships without one + - `explain --list` was silently dropping every code its if-else prefix chain + did not match — all `W_` and `I_` codes. Replaced with a prefix table plus + an explicit fallback bucket From e60289f035c73528a7efc21c3c57049e0adbfd92 Mon Sep 17 00:00:00 2001 From: Frank O'Hara Date: Thu, 13 Aug 2026 10:11:13 -0600 Subject: [PATCH 2/2] fix(docs): drop intra-doc link to a private item rustdoc -D warnings rejects a public doc linking to a private method. --- crates/lash-core/src/linter/rules/crossfile/orphaned_files.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/lash-core/src/linter/rules/crossfile/orphaned_files.rs b/crates/lash-core/src/linter/rules/crossfile/orphaned_files.rs index f198626..ae16552 100644 --- a/crates/lash-core/src/linter/rules/crossfile/orphaned_files.rs +++ b/crates/lash-core/src/linter/rules/crossfile/orphaned_files.rs @@ -15,8 +15,8 @@ use crate::linter::{LintContext, LintDiagnostic, LintRule}; /// This rule checks that all task files in the project are referenced in the root /// index. Files not in the index are considered "orphaned" and generate a warning. /// -/// Common documentation filenames and documentation directories are exempt (see -/// [`OrphanedFilesRule::should_skip_orphan_check`]). Anything else that is not a +/// Common documentation filenames (README.md, CHANGELOG.md, …) and documentation +/// directories (`docs/`, `.github/`, …) are exempt. Anything else that is not a /// task file — prose, generated content — belongs in a `.lashignore` at the /// project root, which removes it from file discovery entirely. ///