From 1c43740bd667921ac9556486eab583aaeaae9aa8 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 8 Sep 2026 11:26:35 +0100 Subject: [PATCH 1/5] docs: add lychee linter issue specification --- .../ISSUE.md | 177 ++++++++++++++++++ .../questions.md | 114 +++++++++++ 2 files changed, 291 insertions(+) create mode 100644 docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md create mode 100644 docs/issues/3-add-lychee-local-markdown-link-checker/questions.md diff --git a/docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md b/docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md new file mode 100644 index 0000000..a4e63f7 --- /dev/null +++ b/docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md @@ -0,0 +1,177 @@ +--- +doc-type: issue +issue-type: feature +status: draft +github-issue: 3 +branch: feat/add-lychee-local-markdown-link-checker +related-tracker-issue: https://github.com/torrust/torrust-tracker/issues/2150 +related-tracker-follow-up: https://github.com/torrust/torrust-tracker/issues/2162 +--- + +# Issue #3 - Add lychee local Markdown link checker + +## Goal + +Add [lychee](https://lychee.cli.rs/) to the shared `linter` CLI so Torrust repositories can +deterministically validate local Markdown file links and fragments with `linter lychee` and +`linter all`. + +## Background + +The Torrust Tracker repository established the consuming-project convention in +[tracker issue #2150](https://github.com/torrust/torrust-tracker/issues/2150): a root +`lychee.toml` enables `offline = true` and `include_fragments = "full"`. This checks local +Markdown targets, heading anchors, and text fragments without making network requests. Its +baseline is clean after excluding immutable historical issue specifications with a narrowly +documented `exclude_path` rule. + +The common `torrust-linting` binary currently supports Markdown, YAML, TOML, CSpell, Clippy, +Rustfmt, and ShellCheck. It needs a corresponding lychee runner before Tracker can integrate +the deterministic local-link check into its pre-commit hook and ordinary CI in issue #2162. + +## Scope + +### In scope + +- Add a `Lychee` CLI command, exposed as `linter lychee`. +- Add `src/linters/lychee.rs`, exporting `run_lychee_linter() -> anyhow::Result<()>` through + `src/linters/mod.rs` and `src/lib.rs`. +- Run lychee from the current working directory against the repository's Markdown files, + passing `**/*.md` and `.github/**/*.md` for explicit dot-directory coverage. Preserve normal + ignore behavior so generated `target/` content is not checked. +- Allow lychee to automatically use a root `lychee.toml` when that file exists; do not require + another configuration filename or hard-code consumer-specific configuration. +- Always pass `--offline` so the shared CLI is a deterministic local-link check even when a + consuming project has no `lychee.toml` or its configuration is incomplete. +- Install lychee automatically when it is absent, following the existing external-tool pattern. + Use Cargo to install the `lychee` package, after confirming that Cargo is available. +- Include the runner in `run_all_linters()` while retaining the existing behavior of attempting + every linter and returning an error if one or more fail. +- Preserve this repository's self-linting CI contract: `.github/workflows/linting.yml` builds the + `linter` binary and runs `./target/debug/linter all`, so it will exercise lychee against this + repository as well as verify automatic tool installation in a clean runner. +- Preserve lychee standard output, standard error, and a nonzero failure result for broken local + file links or fragments. +- Update `README.md` to document the feature list, `cargo run -- lychee` command, manual + installation, and root `lychee.toml` configuration file. +- Do not introduce a process abstraction solely for this linter. Verify direct external-command + execution with valid and deliberately invalid local Markdown links, plus the mandatory + end-to-end linter run. + +### Out of scope + +- Enabling online or external URL checks in `linter lychee` or `linter all`. +- Adding an online-mode flag or an alternate-config-file interface to the shared linter. A + consuming repository's advisory scheduled/manual workflow invokes the lychee executable + directly with its dedicated online configuration file. +- Adding a `lychee.toml` configuration for this repository solely as part of the shared-linter + feature. +- Tracker-specific configuration, exclusions, pre-commit wiring, CI wiring, or the advisory + scheduled external-link workflow. Those are tracked by Tracker issue #2162. +- Hard-coding Tracker paths or exclusions in this crate. + +## Design + +### Command behavior + +`linter lychee` runs the lychee executable from the caller's current working directory with +`--offline`, `**/*.md`, and `.github/**/*.md`. The explicit `.github` input covers authoritative +documentation in hidden directories; ordinary lychee ignore behavior excludes generated content +such as `target/`. Lychee's standard root configuration discovery remains active, so a consuming +project can control compatible local-check policy through `lychee.toml`. + +The initial supported policy is the Tracker convention: + +```toml +# Consumer-owned configuration example; not embedded in this crate. +offline = true +include_fragments = "full" + +# Each consumer exclusion must be narrow and document its rationale. +exclude_path = [ "(^|/)docs/issues/closed/" ] +``` + +The shared CLI enforces offline mode independently of the configuration. `offline = true` is +still recommended in consumer configuration to make the local-only policy explicit for direct +lychee use. Full fragment checking validates both heading anchors and text fragments in local +Markdown links. Optional external-link monitoring is explicitly outside this CLI: consuming +repositories invoke lychee directly from advisory scheduled/manual workflows with a separate +online configuration file. That repository-owned workflow controls its network policy, +`GITHUB_TOKEN`, request limits, artifacts, and triage process. + +### Error handling + +The runner follows the external linter pattern already used by Markdown, TOML, and CSpell: + +1. Detect the lychee executable in `PATH`. +2. If unavailable, install the latest available release with `cargo install lychee` and propagate + installation failures with context. Do not pin a version; handle future compatibility changes + through the normal dependency update and validation workflow. +3. Execute lychee, forwarding its diagnostic output when it exits unsuccessfully. +4. Return an error to make the specific command and `linter all` fail. + +## Implementation plan + +| ID | Status | Task | +| --- | ------ | ------------------------------------------------------------------------------------------ | +| T1 | TODO | Add Cargo-based lychee installation helper and linter runner. | +| T2 | TODO | Register and re-export the new linter module. | +| T3 | TODO | Add the `lychee` subcommand and run it from `all`. | +| T4 | TODO | Update README feature, usage, tool-installation, and configuration documentation. | +| T5 | TODO | Verify valid and deliberately invalid local-link scenarios with the direct CLI. | +| T6 | TODO | Format and run the required validation commands, including the mandatory `cargo run` gate. | + +## Acceptance criteria + +- [ ] `linter lychee` invokes lychee in the caller's current project directory. +- [ ] A consuming project's root `lychee.toml` is honored through lychee's default discovery. +- [ ] The documented `offline = true` and `include_fragments = "full"` convention supports + deterministic local Markdown file and fragment validation. +- [ ] The CLI always passes `--offline`, preventing normal linter runs from checking external + URLs even if consumer configuration is absent or incomplete. +- [ ] Missing lychee is installed through Cargo, or a clear installation error is returned. +- [ ] Lychee's diagnostics are displayed and a broken local link or fragment causes a nonzero + result from both `linter lychee` and `linter all`. +- [ ] `linter all` continues running remaining linters after a lychee failure and reports an + aggregate failure. +- [ ] The normal linter path does not enable online external URL checking. +- [ ] README documentation lists lychee, its command, installation command, and `lychee.toml`. +- [ ] `cargo fmt --check`, `cargo clippy -- -D warnings`, and `cargo run` exit successfully. + +## Verification plan + +| ID | Check | Expected result | +| --- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| V1 | `cargo fmt --check` | Formatting check succeeds. | +| V2 | `cargo clippy -- -D warnings` | No Clippy warnings. | +| V3 | `cargo run -- lychee` | Lychee runs offline from the repository root with `**/*.md` and `.github/**/*.md`, then succeeds. | +| V4 | `cargo run` | Mandatory self-linting repository gate exits with code 0. | +| V5 | `.github/workflows/linting.yml` behavior | Its `./target/debug/linter all` invocation installs lychee when absent and successfully self-lints the repository. | +| V6 | Run `linter lychee` in a temporary directory containing an invalid local link | Lychee diagnostic is shown and the command exits nonzero without network access. | + +## Risks and decisions needed + +- **Markdown input set:** `**/*.md` plus `.github/**/*.md` establishes one general discovery + policy without duplicating the Tracker-specific input list. Consumer repositories own their + exclusions through `lychee.toml`. +- **Latest lychee installation:** The unpinned installation policy keeps the tool fresh but can + introduce future behavior changes. Mitigation: validate releases through the normal dependency + update workflow rather than retaining a stale version pin. +- **No isolated runner unit tests:** Direct process execution matches every existing linter. The + valid/invalid local-link scenarios and mandatory end-to-end gate provide the required evidence + without introducing a one-off abstraction. +- **Self-linting bootstrap:** CI runs `linter all` from a clean runner, so the first lychee run + may install the tool. Mitigation: use the existing Cargo installation pattern and keep the + workflow's Rust toolchain setup and cache compatible with `cargo install lychee`. +- **Online policy scope:** Shared online support would require another configuration, secret, and + network policy that is incompatible with the normal linter contract. Mitigation: consumers run + the lychee executable directly in separately configured advisory workflows; add shared online + support only when multiple consumers establish stable, common requirements. + +## References + +- GitHub issue: +- Tracker configuration and baseline: +- Tracker integration follow-up: +- Lychee configuration: +- Lychee fragment checking: diff --git a/docs/issues/3-add-lychee-local-markdown-link-checker/questions.md b/docs/issues/3-add-lychee-local-markdown-link-checker/questions.md new file mode 100644 index 0000000..ecdf8de --- /dev/null +++ b/docs/issues/3-add-lychee-local-markdown-link-checker/questions.md @@ -0,0 +1,114 @@ +# Questions for Issue #3 + +Reply beneath each question. Keep the selected option or provide a replacement decision, then add +any relevant rationale. + +## Q1 - Markdown input set + +Which Markdown input set should `linter lychee` pass to lychee? + +- **Option A (recommended):** Pass `**/*.md` plus `.github/**/*.md`, with lychee configured or + invoked so generated `target/` content is not checked. +- **Option B:** Use the explicit Tracker input set: + `README.md`, `SECURITY.md`, `docs/**/*.md`, `**/AGENTS.md`, `packages/*/README.md`, + `console/**/*.md`, `contrib/**/*.md`, `share/**/*.md`, and `.github/**/*.md`. +- **Option C:** Another input set (describe it below). + +**Answer:** Option A, with explicit dot-directory coverage: pass `**/*.md` and +`.github/**/*.md`. Do not duplicate the Tracker-specific input list in the shared linter. + +The shared tool must apply one general Markdown-discovery policy across Torrust repositories; +each consuming repository provides its own exclusions and lychee behavior through root +`lychee.toml`. Include `.github/**/*.md` explicitly because ordinary recursive glob expansion may +not include hidden directories. Generated `target/` content must remain excluded through normal +ignore behavior (or the same narrowly scoped discovery exclusion used by existing linters). + +## Q2 - Lychee installation version + +Which automatic installation policy should the linter use? + +- **Option A (recommended):** `cargo install lychee` without a version pin, matching the existing + external-tool installation approach. +- **Option B:** Install a pinned lychee release (state the exact version), trading ongoing update + maintenance for reproducibility. +- **Option C:** Another policy (describe it below). + +**Answer:** Option A: use `cargo install lychee` without a version pin. + +This follows the repository dependency-freshness policy and the existing external-tool +installation approach. The Tracker report pins `0.24.2` only to reproduce the historical baseline +that was measured there; it is not a compatibility constraint for the shared linter. If a future +lychee release requires a behavior/configuration change, handle it through the normal dependency +update and validation workflow rather than retaining a stale pin. + +## Q3 - Automated tests + +Should this issue introduce test infrastructure for an external command runner? + +- **Option A (recommended):** Do not introduce process abstraction solely for tests. Verify through + `cargo run -- lychee`, a manual invalid-link scenario, and the mandatory `cargo run` gate. +- **Option B:** Introduce a test seam and focused automated tests for command construction, + configuration discovery, output forwarding, and failure propagation. +- **Option C:** Another testing approach (describe it below). + +**Answer:** Option A: do not introduce a process abstraction solely for this linter. + +`torrust-linting` currently has no unit-test suite and its established verification is running the +real CLI against the repository (`cargo run`, which CI also executes). Keep the implementation +consistent with existing direct `Command`-based linters, verify `cargo run -- lychee` on valid and +temporarily invalid local Markdown links, and confirm that `cargo run` includes lychee through +`all`. A test seam is justified only if the project adopts one across external-tool runners, not +for this single linter. + +## Q4 - Enforce offline mode by the shared CLI + +Should `linter lychee` always pass Lychee's `--offline` flag? + +- **Option A (recommended):** Yes. The shared CLI always enforces offline mode, so `linter all` + can never perform network-dependent checksβ€”even when a consuming repository has no + `lychee.toml`. A root `lychee.toml` still supplies fragment checking and consumer-owned + exclusions. +- **Option B:** No. The CLI relies entirely on each consuming project's `lychee.toml` for + `offline = true`. This means projects without that configuration could accidentally perform + online checks. +- **Option C:** Another policy (describe it below). + +**Answer:** Option A: `linter lychee` must always pass Lychee's `--offline` flag. + +The shared CLI's contract is deterministic, local Markdown link validation. Relying only on a +consumer's `lychee.toml` makes `linter all` network-dependent whenever that file is absent or +misconfigured, which conflicts with the purpose of a fast, reliable pre-commit and normal-CI +check. A root `lychee.toml` remains the consumer-owned place for `include_fragments`, exclusions, +timeouts, and other compatible local-check settings; `--offline` is the shared safety boundary. + +Online external-link validation belongs to a separately invoked Lychee command in an advisory +scheduled/manual workflow, not to `linter lychee` or `linter all`. + +## Q5 - Scheduled online link checking + +How should the shared CLI support an advisory scheduled workflow that checks external URLs? + +- **Option A (recommended):** Add `linter lychee --online --config `. The default command + and `linter all` remain offline. `--online` omits the wrapper's `--offline` flag; the scheduled + workflow explicitly selects a separate config file, such as `lychee-online.toml`, which does + not set `offline = true`. The wrapper passes the config path to lychee. +- **Option B:** Add only `linter lychee --online`, relying on the root `lychee.toml` not to set + `offline = true`. This conflicts with the Tracker's intended local-check configuration unless + that configuration is changed for every scheduled run. +- **Option C:** Do not add online support to `linter`; the scheduled workflow invokes the lychee + executable directly with an online-specific config file. +- **Option D:** Another interface (describe it below). + +**Answer:** Option C: do not add online support to `linter` in this issue. + +Issue #3 establishes the shared CLI's deterministic offline contract. An online mode would add a +second configuration/secret/network policy to a tool intended for normal local and CI linting, +despite being neither required nor safe for `linter all`. The tracker follow-up, +`torrust/torrust-tracker#2162`, should invoke the Lychee executable directly from its advisory +weekly and manually dispatched workflow using a dedicated online configuration file (for example, +`lychee-online.toml`) that does not set `offline = true`. + +This preserves a clear boundary: the shared linter protects deterministic local documentation +links, while the consuming repository owns optional external-link monitoring, `GITHUB_TOKEN`, +request limits, artifacts, and operational triage. A future dedicated issue can add shared online +support only if several consuming repositories demonstrate the same stable requirements. From 11c378404910c5ff2dd5b1e0cca5a3ac2f59886c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 8 Sep 2026 11:46:05 +0100 Subject: [PATCH 2/5] docs: repair stale feature documentation links --- docs/features/linter-auto-fix/README.md | 18 +++++++----------- docs/features/linter-auto-fix/specification.md | 12 +++++++----- .../linter-parallel-execution/questions.md | 8 ++++++-- .../linter-parallel-execution/specification.md | 8 ++++++-- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/docs/features/linter-auto-fix/README.md b/docs/features/linter-auto-fix/README.md index b77ab20..74bd55e 100644 --- a/docs/features/linter-auto-fix/README.md +++ b/docs/features/linter-auto-fix/README.md @@ -78,33 +78,27 @@ cargo run --bin linter markdown --fix Based on answers in [questions.md](./questions.md), the following key decisions were made: 1. **YAML Tool**: Use **yamlfmt** (not prettier) for YAML formatting - - Reason: More focused tool, simpler to integrate 2. **Fix Scope**: Fix the same files that current linter checks (Option B) - - No expansion to additional file types - Consistent with existing linter behavior 3. **Output Verbosity**: **Minimal** - show only "Fixed N files" summaries - - Keep output clean and focused - Users can see detailed changes via `git diff` - Only display errors that still need attention 4. **Error Handling**: **Auto-install missing tools** (Option D) - - Matches current linter behavior - Seamless developer experience - Logs installation for transparency 5. **Testing Strategy**: Unit + Integration + E2E + Manual - - Comprehensive but focused - No property-based testing needed for initial version 6. **Implementation**: Incremental - one linter at a time - - Easier to review, test, and commit - Reduces risk and complexity - Can deploy partial functionality @@ -113,9 +107,11 @@ Based on answers in [questions.md](./questions.md), the following key decisions - Let git track changes naturally - Developers review with `git diff` before committing -## οΏ½πŸ”— Related Documentation +## Related Documentation + +- [Linter Auto-fix Specification](./specification.md) +- [Linter Parallel Execution Feature](../linter-parallel-execution/README.md) + +## Additional Resources -- [Error Handling Guide](../../contributing/error-handling.md) -- [Development Principles](../../development-principles.md) -- [Linting Guide](../../contributing/linting.md) -- [Logging Guide](../../contributing/logging-guide.md) +- [Torrust Linting README](../../../README.md) diff --git a/docs/features/linter-auto-fix/specification.md b/docs/features/linter-auto-fix/specification.md index ba07886..c800b9e 100644 --- a/docs/features/linter-auto-fix/specification.md +++ b/docs/features/linter-auto-fix/specification.md @@ -402,18 +402,20 @@ This ensures developers format code locally before pushing. - Confirm git shows unstaged changes after fix - Test both `all` and individual linter modes -## πŸ“š Related Documentation +## Related Documentation -- [Error Handling Guide](../../contributing/error-handling.md) -- [Development Principles](../../development-principles.md) -- [Linting Guide](../../contributing/linting.md) +- [Linter Auto-fix Feature README](./README.md) +- [Parallel Linter Execution Feature](../linter-parallel-execution/README.md) + +## Additional Resources + +- [Torrust Linting README](../../../README.md) ## πŸ”„ Future Enhancements (Not in Scope) Potential future additions (implement only if needed): 1. **Parallel execution**: Run linters in parallel for better performance (~30% faster, 13s β†’ 9s) - - This is a separate feature with its own specification - See [Parallel Linter Execution Feature](../linter-parallel-execution/specification.md) for details - Compatible with auto-fix but not required for auto-fix functionality diff --git a/docs/features/linter-parallel-execution/questions.md b/docs/features/linter-parallel-execution/questions.md index a0817a7..9f308cd 100644 --- a/docs/features/linter-parallel-execution/questions.md +++ b/docs/features/linter-parallel-execution/questions.md @@ -329,8 +329,12 @@ Once you've answered these questions and decided to implement: 5. Begin incremental refactoring 6. Test thoroughly before merging -## πŸ”— Related Documentation +## Related Documentation +- [Linter Parallel Execution README](./README.md) - [Linter Parallel Execution Specification](./specification.md) - [Linter Auto-fix Feature](../linter-auto-fix/specification.md) -- [Linting Guide](../../contributing/linting.md) + +## Additional Resources + +- [Torrust Linting README](../../../README.md) diff --git a/docs/features/linter-parallel-execution/specification.md b/docs/features/linter-parallel-execution/specification.md index b4cf546..a13ea50 100644 --- a/docs/features/linter-parallel-execution/specification.md +++ b/docs/features/linter-parallel-execution/specification.md @@ -355,8 +355,12 @@ If/when this feature is implemented: ## πŸ“š Related Documentation - [Linter Auto-fix Feature](../linter-auto-fix/specification.md) - May interact with parallel execution -- [Development Principles](../../development-principles.md) -- [Linting Guide](../../contributing/linting.md) +- [Linter Parallel Execution README](./README.md) +- [Linter Parallel Execution Questions](./questions.md) + +## Additional Resources + +- [Torrust Linting README](../../../README.md) ## οΏ½ Alternative Approach: Process-Level Parallelization From a00a8c1ed86025cd1d141b907fdcb93c86d5b046 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 8 Sep 2026 12:13:11 +0100 Subject: [PATCH 3/5] feat: add lychee local link checker --- README.md | 10 ++- .../ISSUE.md | 4 +- .../verification.md | 50 +++++++++++ project-words.txt | 1 + src/cli.rs | 19 ++++- src/linters/lychee.rs | 82 +++++++++++++++++++ src/linters/mod.rs | 2 + 7 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 docs/issues/3-add-lychee-local-markdown-link-checker/verification.md create mode 100644 src/linters/lychee.rs diff --git a/README.md b/README.md index 707e2e6..2a7f6f4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A unified linting library and CLI for Torrust projects. Provides a single binary ## Features -- **Multiple Linters**: Markdown, YAML, TOML, Rust (clippy + rustfmt), shell scripts, and spell checking +- **Multiple Linters**: Markdown, local Markdown links, YAML, TOML, Rust (clippy + rustfmt), shell scripts, and spell checking - **CLI Ready**: Pre-built CLI binary (`linter`) with subcommands for each linter - **Library**: Use individual linter functions in your own Rust code or build a custom CLI - **Extensible**: Easy to add new linters @@ -21,6 +21,7 @@ Run the `linter` binary directly: ```sh cargo run # Run all linters (default) cargo run -- markdown # Run markdown linter +cargo run -- lychee # Check local Markdown links and fragments offline cargo run -- yaml # Run YAML linter cargo run -- toml # Run TOML linter cargo run -- cspell # Run CSpell spell checker @@ -88,6 +89,7 @@ Some linters delegate to external tools. The library will attempt to install mis | Linter | Tool | Install | | ----------- | --------------------------- | ------------------------------------------------ | | Markdown | `markdownlint` | `npm install -g markdownlint-cli` | +| Local links | `lychee` | `cargo install lychee` | | YAML | `yamllint` | `apt install yamllint` / `pip3 install yamllint` | | TOML | `taplo` | `cargo install taplo-cli --locked` | | Spell check | `cspell` | `npm install -g cspell` | @@ -101,10 +103,16 @@ The linters read configuration from well-known files in the working directory: | File | Used by | | -------------------- | ------------ | | `.markdownlint.json` | markdownlint | +| `lychee.toml` | lychee | | `.yamllint-ci.yml` | yamllint | | `.taplo.toml` | taplo | | `cspell.json` | cspell | +`linter lychee` always passes Lychee's `--offline` flag. This makes normal linting deterministic: +it checks only local Markdown links. Configure local fragment checking and repository-specific +exclusions in `lychee.toml`; use the Lychee executable directly from a separately configured, +advisory scheduled workflow when checking external URLs. + ## License Copyright (c) 2026 Torrust. See [LICENSE](LICENSE). diff --git a/docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md b/docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md index a4e63f7..88404de 100644 --- a/docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md +++ b/docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md @@ -118,7 +118,7 @@ The runner follows the external linter pattern already used by Markdown, TOML, a | T2 | TODO | Register and re-export the new linter module. | | T3 | TODO | Add the `lychee` subcommand and run it from `all`. | | T4 | TODO | Update README feature, usage, tool-installation, and configuration documentation. | -| T5 | TODO | Verify valid and deliberately invalid local-link scenarios with the direct CLI. | +| T5 | DONE | Verify valid and deliberately invalid local-link scenarios with the direct CLI. | | T6 | TODO | Format and run the required validation commands, including the mandatory `cargo run` gate. | ## Acceptance criteria @@ -147,7 +147,7 @@ The runner follows the external linter pattern already used by Markdown, TOML, a | V3 | `cargo run -- lychee` | Lychee runs offline from the repository root with `**/*.md` and `.github/**/*.md`, then succeeds. | | V4 | `cargo run` | Mandatory self-linting repository gate exits with code 0. | | V5 | `.github/workflows/linting.yml` behavior | Its `./target/debug/linter all` invocation installs lychee when absent and successfully self-lints the repository. | -| V6 | Run `linter lychee` in a temporary directory containing an invalid local link | Lychee diagnostic is shown and the command exits nonzero without network access. | +| V6 | Run `linter lychee` in a temporary directory containing an invalid local link | Lychee diagnostic is shown and the command exits nonzero without network access. See `verification.md`. | ## Risks and decisions needed diff --git a/docs/issues/3-add-lychee-local-markdown-link-checker/verification.md b/docs/issues/3-add-lychee-local-markdown-link-checker/verification.md new file mode 100644 index 0000000..9cce04f --- /dev/null +++ b/docs/issues/3-add-lychee-local-markdown-link-checker/verification.md @@ -0,0 +1,50 @@ +# Verification Evidence + +## V6 - Broken local link fails offline + +A temporary directory was created with `broken.md` containing: + +```markdown +[Broken local link](missing.md) +``` + +### Command + +```sh +tmp_dir=$(mktemp -d) +printf '%s\n' '[Broken local link](missing.md)' > "$tmp_dir/broken.md" +cd "$tmp_dir" +set +e +linter lychee +exit_code=$? +set -e +printf '\nVerification exit code: %s\n' "$exit_code" +rm -rf "$tmp_dir" +test "$exit_code" -ne 0 +``` + +### Output + +```text +2026-09-08T11:06:39.299798Z INFO lychee: Checking local Markdown links and fragments... +Issues found in 1 input. Find details below. + +[broken.md]: +[ERROR] file:///tmp//missing.md (at 1:1) | File not found. Check if file exists and path is correct + +πŸ” 1 Total (in 0s) πŸ”— 1 Unique βœ… 0 OK 🚫 1 Error + + + [WARN] .github/**/*.md: No files found for this input source + + +2026-09-08T11:06:39.310622Z ERROR lychee: Local Markdown link checking failed. Please fix the issues above. (0.011s) +Error: Local Markdown link checking failed + +Verification exit code: 1 +``` + +### Result + +**Passed.** Lychee identified the missing local target, the linter forwarded the diagnostic, and +exited with status `1`. The temporary directory was removed after the check. diff --git a/project-words.txt b/project-words.txt index 4c4a33b..d973a06 100644 --- a/project-words.txt +++ b/project-words.txt @@ -3,6 +3,7 @@ cspell dtolnay eprintln markdownlint +mktemp MSRV noconfirm pacman diff --git a/src/cli.rs b/src/cli.rs index e70a097..9aa4857 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,8 +3,8 @@ use clap::{Parser, Subcommand}; use tracing::{error, info, Level}; use crate::linters::{ - run_clippy_linter, run_cspell_linter, run_markdown_linter, run_rustfmt_linter, run_shellcheck_linter, run_toml_linter, - run_yaml_linter, + run_clippy_linter, run_cspell_linter, run_lychee_linter, run_markdown_linter, run_rustfmt_linter, run_shellcheck_linter, + run_toml_linter, run_yaml_linter, }; /// Initialize tracing with default configuration @@ -49,6 +49,9 @@ pub enum Commands { #[command(alias = "md")] Markdown, + /// Check local Markdown links and fragments using lychee + Lychee, + /// Run YAML linter Yaml, @@ -93,6 +96,15 @@ pub fn run_all_linters() -> Result<()> { } } + // Run local Markdown link checker + match run_lychee_linter() { + Ok(()) => {} + Err(e) => { + error!("Local Markdown link checking failed: {e}"); + failed = true; + } + } + // Run YAML linter match run_yaml_linter() { Ok(()) => {} @@ -165,6 +177,9 @@ pub fn execute_command(command: Option<&Commands>) -> Result<()> { Some(Commands::Markdown) => { run_markdown_linter()?; } + Some(Commands::Lychee) => { + run_lychee_linter()?; + } Some(Commands::Yaml) => { run_yaml_linter()?; } diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs new file mode 100644 index 0000000..9b3a6c6 --- /dev/null +++ b/src/linters/lychee.rs @@ -0,0 +1,82 @@ +use std::env; +use std::process::Command; +use std::time::Instant; + +use anyhow::Result; +use tracing::{error, info}; + +use crate::utils::is_command_available; + +/// Install lychee using Cargo. +/// +/// # Errors +/// +/// Returns an error if Cargo is not available or if the installation fails. +fn install_lychee() -> Result<()> { + info!(target: "lychee", "Installing lychee..."); + + if !is_command_available("cargo") { + error!(target: "lychee", "Cargo is required to install lychee"); + return Err(anyhow::anyhow!("Cargo is not available")); + } + + let output = Command::new("cargo").args(["install", "lychee"]).output()?; + + if output.status.success() { + info!(target: "lychee", "lychee installed successfully"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + error!(target: "lychee", "Failed to install lychee: {stderr}"); + Err(anyhow::anyhow!("Failed to install lychee")) + } +} + +/// Run lychee to check local Markdown links and fragments. +/// +/// Lychee automatically discovers a root `lychee.toml` from the current working directory. This +/// runner always uses offline mode to keep the standard linting workflow deterministic. +/// +/// # Errors +/// +/// Returns an error if lychee is not available, cannot be installed, or reports broken links. +pub fn run_lychee_linter() -> Result<()> { + if !is_command_available("lychee") { + install_lychee()?; + } + + let repo_root = env::current_dir()?; + let t = Instant::now(); + info!(target: "lychee", "Checking local Markdown links and fragments..."); + + let output = Command::new("lychee") + .current_dir(&repo_root) + .args([ + "--offline", + "--no-progress", + "--exclude-path", + "(^|/)target/", + "**/*.md", + ".github/**/*.md", + ]) + .output()?; + + if output.status.success() { + info!(target: "lychee", "All local Markdown links passed checking! ({:.3}s)", t.elapsed().as_secs_f64()); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + + if !stdout.is_empty() { + println!("{stdout}"); + } + if !stderr.is_empty() { + eprintln!("{stderr}"); + } + + println!(); + error!(target: "lychee", "Local Markdown link checking failed. Please fix the issues above. ({:.3}s)", t.elapsed().as_secs_f64()); + Err(anyhow::anyhow!("Local Markdown link checking failed")) + } +} diff --git a/src/linters/mod.rs b/src/linters/mod.rs index defe620..c4d02b5 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -1,5 +1,6 @@ pub mod clippy; pub mod cspell; +pub mod lychee; pub mod markdown; pub mod rustfmt; pub mod shellcheck; @@ -8,6 +9,7 @@ pub mod yaml; pub use clippy::*; pub use cspell::*; +pub use lychee::*; pub use markdown::*; pub use rustfmt::*; pub use shellcheck::*; From be2d6f79249e1ff1760df08441024280982afd95 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 8 Sep 2026 12:27:45 +0100 Subject: [PATCH 4/5] docs: plan post-merge lychee release --- .../ISSUE.md | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md b/docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md index 88404de..9416286 100644 --- a/docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md +++ b/docs/issues/3-add-lychee-local-markdown-link-checker/ISSUE.md @@ -112,14 +112,15 @@ The runner follows the external linter pattern already used by Markdown, TOML, a ## Implementation plan -| ID | Status | Task | -| --- | ------ | ------------------------------------------------------------------------------------------ | -| T1 | TODO | Add Cargo-based lychee installation helper and linter runner. | -| T2 | TODO | Register and re-export the new linter module. | -| T3 | TODO | Add the `lychee` subcommand and run it from `all`. | -| T4 | TODO | Update README feature, usage, tool-installation, and configuration documentation. | -| T5 | DONE | Verify valid and deliberately invalid local-link scenarios with the direct CLI. | -| T6 | TODO | Format and run the required validation commands, including the mandatory `cargo run` gate. | +| ID | Status | Task | +| --- | ------ | ------------------------------------------------------------------------------------------------------------ | +| T1 | TODO | Add Cargo-based lychee installation helper and linter runner. | +| T2 | TODO | Register and re-export the new linter module. | +| T3 | TODO | Add the `lychee` subcommand and run it from `all`. | +| T4 | TODO | Update README feature, usage, tool-installation, and configuration documentation. | +| T5 | DONE | Verify valid and deliberately invalid local-link scenarios with the direct CLI. | +| T6 | TODO | Format and run the required validation commands, including the mandatory `cargo run` gate. | +| T7 | TODO | After the implementation PR merges, release the backward-compatible feature as version `0.2.0` on crates.io. | ## Acceptance criteria @@ -137,6 +138,8 @@ The runner follows the external linter pattern already used by Markdown, TOML, a - [ ] The normal linter path does not enable online external URL checking. - [ ] README documentation lists lychee, its command, installation command, and `lychee.toml`. - [ ] `cargo fmt --check`, `cargo clippy -- -D warnings`, and `cargo run` exit successfully. +- [ ] After the PR merges, the crate version is bumped from `0.1.0` to `0.2.0`, published to + crates.io, and released with the corresponding version tag. ## Verification plan @@ -148,6 +151,7 @@ The runner follows the external linter pattern already used by Markdown, TOML, a | V4 | `cargo run` | Mandatory self-linting repository gate exits with code 0. | | V5 | `.github/workflows/linting.yml` behavior | Its `./target/debug/linter all` invocation installs lychee when absent and successfully self-lints the repository. | | V6 | Run `linter lychee` in a temporary directory containing an invalid local link | Lychee diagnostic is shown and the command exits nonzero without network access. See `verification.md`. | +| V7 | Post-merge release | `0.2.0` is available on crates.io and the matching Git tag/release points to the merged commit. | ## Risks and decisions needed From 5da34afce46bac32aefcdbcec6cab75863a89eb1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 8 Sep 2026 12:34:54 +0100 Subject: [PATCH 5/5] fix: refine lychee file discovery --- .../verification.md | 3 ++- src/linters/lychee.rs | 27 +++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/issues/3-add-lychee-local-markdown-link-checker/verification.md b/docs/issues/3-add-lychee-local-markdown-link-checker/verification.md index 9cce04f..17d8779 100644 --- a/docs/issues/3-add-lychee-local-markdown-link-checker/verification.md +++ b/docs/issues/3-add-lychee-local-markdown-link-checker/verification.md @@ -11,11 +11,12 @@ A temporary directory was created with `broken.md` containing: ### Command ```sh +LINTER_BINARY="$(pwd)/target/debug/linter" tmp_dir=$(mktemp -d) printf '%s\n' '[Broken local link](missing.md)' > "$tmp_dir/broken.md" cd "$tmp_dir" set +e -linter lychee +"$LINTER_BINARY" lychee exit_code=$? set -e printf '\nVerification exit code: %s\n' "$exit_code" diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index 9b3a6c6..d67fab9 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -49,17 +49,22 @@ pub fn run_lychee_linter() -> Result<()> { let t = Instant::now(); info!(target: "lychee", "Checking local Markdown links and fragments..."); - let output = Command::new("lychee") - .current_dir(&repo_root) - .args([ - "--offline", - "--no-progress", - "--exclude-path", - "(^|/)target/", - "**/*.md", - ".github/**/*.md", - ]) - .output()?; + let mut command = Command::new("lychee"); + command.current_dir(&repo_root).args([ + "--offline", + "--no-progress", + "--exclude-path", + "(^|/)target/", + "--exclude-path", + "(^|/)\\.terraform/", + "**/*.md", + ]); + + if repo_root.join(".github").is_dir() { + command.arg(".github/**/*.md"); + } + + let output = command.output()?; if output.status.success() { info!(target: "lychee", "All local Markdown links passed checking! ({:.3}s)", t.elapsed().as_secs_f64());