Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions crates/lash-agent/src/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,13 +256,21 @@ is `done` or `waived`.
@depends-on: features/auth.md#task:auth.session
```

Forms:
- `path/to/file.md` — depend on the entire file
- `path/to/file.md#task:<id>` — depend on a specific task
- `#task:<id>` — depend on a task in the same file

Validate references with `lash check-links`. Auto-fix typos in references
with `lash check-links --fix`.
Forms (the `task:` prefix on a fragment is optional everywhere):
- `<task-@id>` — a task by its `@id` (same file, or unique in the project)
- `#task:<id>` / `#<id>` — a task in the same file
- `<file-id>#task:<id>` / `<file-id>#<id>` — a task in another file, by file `@id`
- `path/to/file.md#task:<id>` — a task in another file, by path
- `path/to/file.md` — the entire file
- `<file-id>` — the entire file, by file `@id`

List several dependencies with commas or repeated lines:
`@depends-on: auth.session, payment.gateway`.

`lash complete` refuses while any dependency is still open (pass `--force`
to override). Validate references with `lash check-links` — it reports the
same broken references as `lash lint`. Auto-fix typos with
`lash check-links --fix`.

## `@doc:` — Documentation References

Expand Down
18 changes: 18 additions & 0 deletions crates/lash-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,12 @@ pub enum Commands {
/// and are never auto-completed.
#[arg(long)]
cascade: bool,

/// Complete even when a resolvable @depends-on target is still open.
/// By default completion is refused until dependencies are done or
/// waived.
#[arg(long)]
force: bool,
},

/// Mark one or more tasks as in-progress
Expand Down Expand Up @@ -1113,11 +1119,13 @@ mod tests {
task_ids,
dry_run,
cascade,
force,
} = cli.command
{
assert_eq!(task_ids, vec!["test#task-1"]);
assert!(!dry_run);
assert!(!cascade);
assert!(!force);
} else {
panic!("Expected Complete command");
}
Expand Down Expand Up @@ -1160,6 +1168,16 @@ mod tests {
}
}

#[test]
fn test_cli_parse_complete_force() {
let cli = LashCli::try_parse_from(["lash", "complete", "--force", "test#task-1"]).unwrap();
if let Commands::Complete { force, .. } = cli.command {
assert!(force);
} else {
panic!("Expected Complete command");
}
}

#[test]
fn test_cli_parse_complete_requires_task_id() {
let result = LashCli::try_parse_from(["lash", "complete"]);
Expand Down
137 changes: 137 additions & 0 deletions crates/lash-cli/src/commands/check_links/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ use anyhow::{Context, Result};
use lash_cli::error_reporter::{ErrorDisplayMode, ErrorReporter, ErrorReporterConfig};
use lash_cli::formatter::{OutputFormat, Verbosity};
use lash_cli::theme::CliTheme;
use lash_core::dependency::reference::resolve_reference;
use lash_core::linter::rules::semantic::BrokenDocFragmentRule;
use lash_core::linter::{LintContext, LintDiagnostic, LintRule};
use lash_core::parser::parse_file;
use lash_db::open_database;
use lash_types::dependency::DependencyKind;
use lash_types::error::LashError;
use lash_types::{LashConfig, TaskFile};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -208,6 +210,77 @@ pub fn find_broken_links(conn: impl AsRef<std::path::Path>) -> Result<BrokenLink
})
}

/// Walk every task file under `project_root` and report `@depends-on`
/// references that don't resolve.
///
/// `lash check-links` historically only reported dependencies the *indexer*
/// had already marked broken (a `NULL` `to_task_id` row) — but explicit
/// `@depends-on` edges aren't stored that way, so `check-links` silently
/// passed while `lash lint` flagged the very same references (GitHub issue
/// #19). This reparses the tree and resolves each reference with the **same**
/// [`resolve_reference`] used by the linter, so the two surfaces always agree.
///
/// Files that fail to parse are skipped (surfaced by `lash lint` instead).
#[must_use]
pub fn find_broken_dependencies(project_root: &Path) -> Vec<FileLinks> {
let Ok(markdown_files) = discover_markdown_files(&[project_root.to_path_buf()], true) else {
return Vec::new();
};

let config = LashConfig::from_root(project_root).unwrap_or_default();

let mut files: HashMap<PathBuf, TaskFile> = HashMap::new();
for path in &markdown_files {
let Ok(file) = parse_file(path, &config) else {
continue;
};
let relative = path
.strip_prefix(project_root)
.unwrap_or(path)
.to_path_buf();
files.insert(relative, file);
}

let mut by_file: Vec<FileLinks> = Vec::new();

for (rel_path, file) in &files {
let ctx = LintContext::new(&config, rel_path.clone(), &files);
let mut links: Vec<BrokenLink> = Vec::new();

for task in file.tasks.tasks() {
for dep in &task.metadata.depends_on {
if matches!(
dep.kind,
DependencyKind::Hierarchy | DependencyKind::Directory
) {
continue;
}
let resolve_path = |rel: &str| ctx.resolve_path(Path::new(rel));
if resolve_reference(&dep.target, rel_path, &file.id, &files, resolve_path).is_err()
{
links.push(BrokenLink {
from_task_full_id: lash_types::make_full_id(&file.id, &task.id),
from_file_path: rel_path.display().to_string(),
raw_ref: dep.target.clone(),
kind: dep.kind.as_str().to_string(),
});
}
}
}

if !links.is_empty() {
by_file.push(FileLinks {
file_path: rel_path.display().to_string(),
count: links.len(),
links,
});
}
}

by_file.sort_by(|a, b| a.file_path.cmp(&b.file_path));
by_file
}

/// Walk every task file under `project_root` and report `@doc:` annotations
/// whose `#fragment` does not resolve to a heading in the target document.
///
Expand Down Expand Up @@ -974,6 +1047,70 @@ mod tests {
);
}

// Issue #19: check-links must flag @depends-on references that lint flags.
#[test]
fn test_find_broken_dependencies_flags_unresolvable_ref() {
use tempfile::TempDir;

let temp = TempDir::new().unwrap();
let root = temp.path();

std::fs::write(root.join("lash.index.md"), "# Index\n").unwrap();
std::fs::write(
root.join("tasks.md"),
"# Tasks\n\n@id: repro\n\n## Tasks\n\n\
- [ ] Base\n \
@id: base-task\n\
- [ ] Dependent\n \
@id: dep\n \
@depends-on: does-not-exist\n",
)
.unwrap();

let by_file = find_broken_dependencies(root);
assert_eq!(by_file.len(), 1, "expected one file with a broken dep");
assert_eq!(by_file[0].count, 1);
assert_eq!(by_file[0].links[0].raw_ref, "does-not-exist");
}

// Issue #19: forms that lint accepts must NOT be reported by check-links.
#[test]
fn test_find_broken_dependencies_accepts_valid_forms() {
use tempfile::TempDir;

let temp = TempDir::new().unwrap();
let root = temp.path();

std::fs::write(root.join("lash.index.md"), "# Index\n").unwrap();
std::fs::write(
root.join("tasks.md"),
"# Tasks\n\n@id: repro\n\n## Tasks\n\n\
- [ ] Base\n \
@id: base-task\n\
- [ ] Other\n \
@id: other-task\n\
- [ ] Bare id\n \
@id: d1\n \
@depends-on: base-task\n\
- [ ] Samefile form\n \
@id: d2\n \
@depends-on: #task:base-task\n\
- [ ] File id form\n \
@id: d3\n \
@depends-on: repro#task:base-task\n\
- [ ] Comma list\n \
@id: d4\n \
@depends-on: base-task, other-task\n",
)
.unwrap();

let by_file = find_broken_dependencies(root);
assert!(
by_file.is_empty(),
"all references resolve, expected no broken deps, got {by_file:?}"
);
}

#[test]
fn test_find_broken_doc_fragments_clean_project_returns_empty() {
use tempfile::TempDir;
Expand Down
18 changes: 18 additions & 0 deletions crates/lash-cli/src/commands/check_links/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ pub fn execute(args: &CheckLinksArgs) -> Result<i32> {
// Find all broken links
let mut report = core::find_broken_links(&db_path).context("Failed to find broken links")?;

// Explicit @depends-on edges aren't stored as NULL-target rows, so the DB
// query above never sees them. Reparse the tree and validate each
// reference with the same resolver `lash lint` uses, then merge the
// results in so the two commands agree (GitHub issue #19).
for file_links in core::find_broken_dependencies(&project_root) {
report.total_broken += file_links.count;
if let Some(existing) = report
.by_file
.iter_mut()
.find(|fl| fl.file_path == file_links.file_path)
{
existing.count += file_links.count;
existing.links.extend(file_links.links);
} else {
report.by_file.push(file_links);
}
}

// Additionally scan for broken @doc: fragment references. These are
// semantic warnings raised by `lash lint`, but `check-links` is the
// natural canonical "are my references intact?" command, so it would be
Expand Down
Loading
Loading