Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package rumdl for openSUSE:Factory checked 
in at 2026-03-27 16:50:54
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/rumdl (Old)
 and      /work/SRC/openSUSE:Factory/.rumdl.new.8177 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "rumdl"

Fri Mar 27 16:50:54 2026 rev:51 rq:1343115 version:0.1.61

Changes:
--------
--- /work/SRC/openSUSE:Factory/rumdl/rumdl.changes      2026-03-27 
06:49:52.758890684 +0100
+++ /work/SRC/openSUSE:Factory/.rumdl.new.8177/rumdl.changes    2026-03-27 
16:53:38.817845346 +0100
@@ -1,0 +2,13 @@
+Fri Mar 27 06:00:36 UTC 2026 - Johannes Kastl 
<[email protected]>
+
+- Update to version 0.1.61:
+  * Added
+    - MD018: Add tags config option to recognize #word patterns as
+      tags independently of flavor (#544)
+  * Fixed
+    - MD042: Treat bare # URL as empty link (#546)
+    - MD013: Ignore punctuation inside inline code for sentence
+      splitting (#545)
+    - MD063: Skip invalid headings like Obsidian-style tags (#544)
+
+-------------------------------------------------------------------

Old:
----
  rumdl-0.1.60.obscpio

New:
----
  rumdl-0.1.61.obscpio

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ rumdl.spec ++++++
--- /var/tmp/diff_new_pack.7dGC65/_old  2026-03-27 16:53:39.913891228 +0100
+++ /var/tmp/diff_new_pack.7dGC65/_new  2026-03-27 16:53:39.913891228 +0100
@@ -17,7 +17,7 @@
 
 
 Name:           rumdl
-Version:        0.1.60
+Version:        0.1.61
 Release:        0
 Summary:        Markdown Linter written in Rust
 License:        MIT

++++++ _service ++++++
--- /var/tmp/diff_new_pack.7dGC65/_old  2026-03-27 16:53:39.957893070 +0100
+++ /var/tmp/diff_new_pack.7dGC65/_new  2026-03-27 16:53:39.965893405 +0100
@@ -3,7 +3,7 @@
     <param name="url">https://github.com/rvben/rumdl.git</param>
     <param name="scm">git</param>
     <param name="submodules">enable</param>
-    <param name="revision">v0.1.60</param>
+    <param name="revision">v0.1.61</param>
     <param name="match-tag">v*.*.*</param>
     <param name="versionformat">@PARENT_TAG@</param>
     <param name="versionrewrite-pattern">v(.*)</param>

++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.7dGC65/_old  2026-03-27 16:53:40.001894912 +0100
+++ /var/tmp/diff_new_pack.7dGC65/_new  2026-03-27 16:53:40.021895749 +0100
@@ -1,6 +1,6 @@
 <servicedata>
 <service name="tar_scm">
                 <param name="url">https://github.com/rvben/rumdl.git</param>
-              <param 
name="changesrevision">9ab9e256cf266908555448f396c725d50c103919</param></service></servicedata>
+              <param 
name="changesrevision">9ce5d163deb00a519597e730f3981c33219752d1</param></service></servicedata>
 (No newline at EOF)
 

++++++ rumdl-0.1.60.obscpio -> rumdl-0.1.61.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/.github/workflows/ci.yml 
new/rumdl-0.1.61/.github/workflows/ci.yml
--- old/rumdl-0.1.60/.github/workflows/ci.yml   2026-03-25 11:45:42.000000000 
+0100
+++ new/rumdl-0.1.61/.github/workflows/ci.yml   2026-03-26 23:27:54.000000000 
+0100
@@ -71,32 +71,10 @@
       - name: Run tests
         run: make test-ci
 
-  check-links:
-    name: Check Links
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v6
-
-      - uses: jdx/mise-action@v4
-        with:
-          experimental: true
-          install: false
-
-      - name: Install mise tools
-        run: |
-          for attempt in 1 2 3; do
-            mise install cargo:lychee --yes && break
-            echo "Attempt $attempt failed, retrying in 10s..."
-            sleep 10
-          done
-
-      - name: Check links
-        run: make check-links
-
   all-checks-passed:
     name: All checks passed
     runs-on: ubuntu-latest
-    needs: [lint, test, check-links]
+    needs: [lint, test]
     if: always()
     steps:
       - name: Verify all checks passed
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/.github/workflows/release.yml 
new/rumdl-0.1.61/.github/workflows/release.yml
--- old/rumdl-0.1.60/.github/workflows/release.yml      2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/.github/workflows/release.yml      2026-03-26 
23:27:54.000000000 +0100
@@ -786,32 +786,6 @@
           echo "- Archives:"
           find artifacts/release-* \( -name "*.tar.gz*" -o -name "*.zip*" \) 
-type f 2>/dev/null | sort
 
-      - name: Update major version tag
-        if: ${{ inputs.dry_run != true }}
-        run: |
-          # Extract major version (e.g., v0 from v0.0.191)
-          TAG_NAME=${GITHUB_REF#refs/tags/}
-          MAJOR_VERSION=${TAG_NAME%%.*}
-
-          echo "Updating $MAJOR_VERSION tag to point to $TAG_NAME"
-
-          # Configure git for tagging
-          git config user.name "github-actions[bot]"
-          git config user.email "github-actions[bot]@users.noreply.github.com"
-
-          # Force-update the major version tag
-          git tag -fa "$MAJOR_VERSION" -m "Update $MAJOR_VERSION tag to 
$TAG_NAME"
-          git push origin "$MAJOR_VERSION" --force
-
-          echo "Users can now use: rvben/rumdl@$MAJOR_VERSION"
-
-      - name: Test major version tag update (dry run)
-        if: ${{ inputs.dry_run == true }}
-        run: |
-          TAG_NAME=${GITHUB_REF#refs/tags/}
-          MAJOR_VERSION=${TAG_NAME%%.*}
-          echo "DRY RUN: Would update $MAJOR_VERSION tag to point to $TAG_NAME"
-
       - name: Notify rumdl-pre-commit
         if: ${{ steps.publish-pypi.outcome == 'success' && inputs.dry_run != 
true }}
         continue-on-error: true
@@ -868,6 +842,39 @@
             https://api.github.com/repos/rvben/homebrew-rumdl/dispatches \
             -d "{\"event_type\": \"rumdl_release\", \"client_payload\": 
{\"version\": \"$VERSION\"}}"
 
+      - name: Update major version tag
+        if: ${{ inputs.dry_run != true }}
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          # Extract major version (e.g., v0 from v0.1.60)
+          TAG_NAME=${GITHUB_REF#refs/tags/}
+          MAJOR_VERSION=${TAG_NAME%%.*}
+          COMMIT_SHA=${GITHUB_SHA}
+
+          echo "Updating $MAJOR_VERSION tag to point to $TAG_NAME 
($COMMIT_SHA)"
+
+          # Use the GitHub API to update the tag ref, avoiding the workflows
+          # permission issue that blocks git push when the repo contains
+          # workflow files.
+          if gh api "repos/${{ github.repository 
}}/git/refs/tags/$MAJOR_VERSION" \
+            --method PATCH --field sha="$COMMIT_SHA" --field force=true; then
+            echo "Updated existing $MAJOR_VERSION tag"
+          else
+            gh api "repos/${{ github.repository }}/git/refs" \
+              --method POST --field ref="refs/tags/$MAJOR_VERSION" --field 
sha="$COMMIT_SHA"
+            echo "Created $MAJOR_VERSION tag"
+          fi
+
+          echo "Users can now use: rvben/rumdl@$MAJOR_VERSION"
+
+      - name: Test major version tag update (dry run)
+        if: ${{ inputs.dry_run == true }}
+        run: |
+          TAG_NAME=${GITHUB_REF#refs/tags/}
+          MAJOR_VERSION=${TAG_NAME%%.*}
+          echo "DRY RUN: Would update $MAJOR_VERSION tag to point to $TAG_NAME"
+
       - name: Dry Run Summary
         if: ${{ inputs.dry_run == true }}
         run: |
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/.pre-commit-config.yaml 
new/rumdl-0.1.61/.pre-commit-config.yaml
--- old/rumdl-0.1.60/.pre-commit-config.yaml    2026-03-25 11:45:42.000000000 
+0100
+++ new/rumdl-0.1.61/.pre-commit-config.yaml    2026-03-26 23:27:54.000000000 
+0100
@@ -87,6 +87,17 @@
         pass_filenames: true
         require_serial: true
 
+  # Link checking
+  - repo: local
+    hooks:
+      - id: check-links
+        name: check links
+        entry: make check-links
+        language: system
+        files: \.md$
+        pass_filenames: false
+        stages: [pre-push]
+
   # Pre-push hooks for comprehensive validation
   - repo: local
     hooks:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/CHANGELOG.md 
new/rumdl-0.1.61/CHANGELOG.md
--- old/rumdl-0.1.60/CHANGELOG.md       2026-03-25 11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/CHANGELOG.md       2026-03-26 23:27:54.000000000 +0100
@@ -7,7 +7,17 @@
 
 ## [Unreleased]
 
-## [0.1.60] - 2026-03-25
+## [0.1.61] - 2026-03-26
+
+### Added
+
+- **MD018**: Add `tags` config option to recognize `#word` patterns as tags 
independently of flavor ([#544](https://github.com/rvben/rumdl/issues/544))
+
+### Fixed
+
+- **MD042**: Treat bare `#` URL as empty link 
([#546](https://github.com/rvben/rumdl/issues/546))
+- **MD013**: Ignore punctuation inside inline code for sentence splitting 
([#545](https://github.com/rvben/rumdl/issues/545))
+- **MD063**: Skip invalid headings like Obsidian-style tags 
([#544](https://github.com/rvben/rumdl/issues/544))
 
 ## [0.1.60] - 2026-03-25
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/Cargo.lock new/rumdl-0.1.61/Cargo.lock
--- old/rumdl-0.1.60/Cargo.lock 2026-03-25 11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/Cargo.lock 2026-03-26 23:27:54.000000000 +0100
@@ -2269,7 +2269,7 @@
 
 [[package]]
 name = "rumdl"
-version = "0.1.60"
+version = "0.1.61"
 dependencies = [
  "anyhow",
  "assert_cmd",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/Cargo.toml new/rumdl-0.1.61/Cargo.toml
--- old/rumdl-0.1.60/Cargo.toml 2026-03-25 11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/Cargo.toml 2026-03-26 23:27:54.000000000 +0100
@@ -1,6 +1,6 @@
 [package]
 name = "rumdl"
-version = "0.1.60"
+version = "0.1.61"
 edition = "2024"
 rust-version = "1.94.0"
 description = "A fast Markdown linter written in Rust (Ru(st) MarkDown Linter)"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/Makefile new/rumdl-0.1.61/Makefile
--- old/rumdl-0.1.60/Makefile   2026-03-25 11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/Makefile   2026-03-26 23:27:54.000000000 +0100
@@ -248,6 +248,18 @@
        @echo ""
        @echo "Configuration: cliff.toml"
 
+_version-bump:
+       @$(MAKE) update-cargo-version VERSION_NO_V=$(VERSION_NO_V)
+       @$(MAKE) update-github-action-version VERSION_NO_V=$(VERSION_NO_V)
+       @$(MAKE) update-all-docs-version NEW_TAG=$(NEW_TAG)
+       @$(MAKE) update-changelog NEW_TAG=$(NEW_TAG) 
VERSION_NO_V=$(VERSION_NO_V) CURRENT=$(CURRENT)
+       @scripts/update-npm-versions.sh
+       @sed -i.bak -E 's/mise use rumdl@[0-9.]+/mise use 
rumdl@$(VERSION_NO_V)/g' README.md && rm -f README.md.bak
+       @git add Cargo.toml Cargo.lock README.md docs/global-settings.md 
CHANGELOG.md scripts/rumdl-action.sh npm/
+       @git commit -m "chore(release): prepare $(NEW_TAG)"
+       @git tag -a $(NEW_TAG) -m "$(NEW_TAG)"
+       @echo "Version $(NEW_TAG) created and committed. Run 'git push origin 
main $(NEW_TAG)' to trigger release workflow."
+
 version-major:
        @echo "Creating new major version tag..."
        $(eval CURRENT := $(shell git describe --tags --abbrev=0 2>/dev/null || 
echo v0.0.0))
@@ -256,14 +268,7 @@
        $(eval NEW_TAG := v$(NEW_MAJOR).0.0)
        $(eval VERSION_NO_V := $(NEW_MAJOR).0.0)
        @echo "Current: $(CURRENT) -> New: $(NEW_TAG)"
-       @$(MAKE) update-cargo-version VERSION_NO_V=$(VERSION_NO_V)
-       @$(MAKE) update-github-action-version VERSION_NO_V=$(VERSION_NO_V)
-       @$(MAKE) update-all-docs-version NEW_TAG=$(NEW_TAG)
-       @$(MAKE) update-changelog NEW_TAG=$(NEW_TAG) 
VERSION_NO_V=$(VERSION_NO_V) CURRENT=$(CURRENT)
-       @git add Cargo.toml Cargo.lock README.md docs/global-settings.md 
CHANGELOG.md scripts/rumdl-action.sh
-       @git commit -m "Bump version to $(NEW_TAG)"
-       @git tag -a $(NEW_TAG) -m "Release $(NEW_TAG)"
-       @echo "Version $(NEW_TAG) created and committed. Run 'git push && git 
push origin $(NEW_TAG)' to trigger release workflow."
+       @$(MAKE) _version-bump NEW_TAG=$(NEW_TAG) VERSION_NO_V=$(VERSION_NO_V) 
CURRENT=$(CURRENT)
 
 version-minor:
        @echo "Creating new minor version tag..."
@@ -274,14 +279,7 @@
        $(eval NEW_TAG := v$(MAJOR).$(NEW_MINOR).0)
        $(eval VERSION_NO_V := $(MAJOR).$(NEW_MINOR).0)
        @echo "Current: $(CURRENT) -> New: $(NEW_TAG)"
-       @$(MAKE) update-cargo-version VERSION_NO_V=$(VERSION_NO_V)
-       @$(MAKE) update-github-action-version VERSION_NO_V=$(VERSION_NO_V)
-       @$(MAKE) update-all-docs-version NEW_TAG=$(NEW_TAG)
-       @$(MAKE) update-changelog NEW_TAG=$(NEW_TAG) 
VERSION_NO_V=$(VERSION_NO_V) CURRENT=$(CURRENT)
-       @git add Cargo.toml Cargo.lock README.md docs/global-settings.md 
CHANGELOG.md scripts/rumdl-action.sh
-       @git commit -m "Bump version to $(NEW_TAG)"
-       @git tag -a $(NEW_TAG) -m "Release $(NEW_TAG)"
-       @echo "Version $(NEW_TAG) created and committed. Run 'git push && git 
push origin $(NEW_TAG)' to trigger release workflow."
+       @$(MAKE) _version-bump NEW_TAG=$(NEW_TAG) VERSION_NO_V=$(VERSION_NO_V) 
CURRENT=$(CURRENT)
 
 version-patch:
        @echo "Creating new patch version tag..."
@@ -293,14 +291,7 @@
        $(eval NEW_TAG := v$(MAJOR).$(MINOR).$(NEW_PATCH))
        $(eval VERSION_NO_V := $(MAJOR).$(MINOR).$(NEW_PATCH))
        @echo "Current: $(CURRENT) -> New: $(NEW_TAG)"
-       @$(MAKE) update-cargo-version VERSION_NO_V=$(VERSION_NO_V)
-       @$(MAKE) update-github-action-version VERSION_NO_V=$(VERSION_NO_V)
-       @$(MAKE) update-all-docs-version NEW_TAG=$(NEW_TAG)
-       @$(MAKE) update-changelog NEW_TAG=$(NEW_TAG) 
VERSION_NO_V=$(VERSION_NO_V) CURRENT=$(CURRENT)
-       @git add Cargo.toml Cargo.lock README.md docs/global-settings.md 
CHANGELOG.md scripts/rumdl-action.sh
-       @git commit -m "Bump version to $(NEW_TAG)"
-       @git tag -a $(NEW_TAG) -m "Release $(NEW_TAG)"
-       @echo "Version $(NEW_TAG) created and committed. Run 'git push && git 
push origin $(NEW_TAG)' to trigger release workflow."
+       @$(MAKE) _version-bump NEW_TAG=$(NEW_TAG) VERSION_NO_V=$(VERSION_NO_V) 
CURRENT=$(CURRENT)
 
 # Target to push the new tag and changes automatically
 version-push:
@@ -441,9 +432,11 @@
        @echo "Generating benchmark chart..."
        @uv run --with matplotlib python3 scripts/generate_benchmark_chart.py
 
+LYCHEE := $(shell command -v lychee 2>/dev/null || echo "mise exec -- lychee")
+
 check-links:
        @echo "Checking links in markdown files..."
-       mise exec -- lychee --no-progress --config .lychee.toml --remap 
'https://rumdl.dev/([^/]+)/? file://$(CURDIR)/docs/$$1.md' 'README.md' 
'docs/**/*.md'
+       $(LYCHEE) --no-progress --config .lychee.toml --remap 
'https://rumdl.dev/([^/]+)/? file://$(CURDIR)/docs/$$1.md' 'README.md' 
'docs/**/*.md'
 
 # Documentation validation
 test-doc-completeness:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/README.md new/rumdl-0.1.61/README.md
--- old/rumdl-0.1.60/README.md  2026-03-25 11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/README.md  2026-03-26 23:27:54.000000000 +0100
@@ -196,7 +196,7 @@
 mise install rumdl
 
 # Use a specific version for the project
-mise use [email protected]
+mise use [email protected]
 ```
 
 ### Using Nix (macOS/Linux)
@@ -346,7 +346,7 @@
 ```yaml
 repos:
   - repo: https://github.com/rvben/rumdl-pre-commit
-    rev: v0.1.60
+    rev: v0.1.61
     hooks:
       - id: rumdl      # Lint only (fails on issues)
       - id: rumdl-fmt  # Auto-format and fail if issues remain
@@ -368,7 +368,7 @@
 ```yaml
 repos:
   - repo: https://github.com/rvben/rumdl-pre-commit
-    rev: v0.1.60
+    rev: v0.1.61
     hooks:
       - id: rumdl
         args: [--no-exclude]  # Disable all exclude patterns
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/docs/_redirects 
new/rumdl-0.1.61/docs/_redirects
--- old/rumdl-0.1.60/docs/_redirects    2026-03-25 11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/docs/_redirects    2026-03-26 23:27:54.000000000 +0100
@@ -1,2 +1,3 @@
 /RULES  /rules  301
 /RULES/ /rules/ 301
+/RULES/* /rules/:splat 301
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/docs/md018.md 
new/rumdl-0.1.61/docs/md018.md
--- old/rumdl-0.1.60/docs/md018.md      2026-03-25 11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/docs/md018.md      2026-03-26 23:27:54.000000000 +0100
@@ -64,6 +64,7 @@
 | Option | Type | Default | Description |
 |--------|------|---------|-------------|
 | `magiclink` | boolean | `false` | Enable MagicLink support for issue/PR 
references |
+| `tags` | boolean | `null` | Recognize `#word` as tags instead of malformed 
headings. Defaults to `true` for Obsidian flavor, `false` otherwise |
 
 ### MagicLink support
 
@@ -102,9 +103,18 @@
 - YAML frontmatter comments
 - Indented patterns (not at column 1)
 
-## Obsidian flavor: Tag syntax support
+## Tag syntax support
 
-When using `flavor = "obsidian"`, this rule automatically skips Obsidian tag 
syntax at the start of a line. In Obsidian, `#tagname` is used for organizing 
notes with tags, not as headings.
+When `tags = true`, this rule skips `#word` patterns that look like tags 
(e.g., `#todo`, `#project/active`) instead of treating them as malformed 
headings.
+
+Tags are recognized when they start with `#` followed by a non-digit, 
non-space character. Multi-hash patterns like `##tag` are always treated as 
malformed headings, and `#123` (starting with a digit) is not a valid tag.
+
+When `tags` is not explicitly set, it defaults to `true` for Obsidian flavor 
and `false` otherwise. This means Obsidian users get tag support automatically, 
while users of other flavors can opt in:
+
+```toml
+[MD018]
+tags = true
+```
 
 ### Example
 
@@ -122,21 +132,12 @@
 
 <!-- rumdl-enable MD018 MD022 MD025 -->
 
-With `flavor = "obsidian"`:
+With `tags = true`:
 
-- `#todo` and `#project/active` are **not flagged** (Obsidian tags)
+- `#todo` and `#project/active` are **not flagged** (recognized as tags)
 - `##Introduction` is **flagged** (multi-hash, clearly a malformed heading)
 - `#123` is **flagged** (tags cannot start with digits)
 
-### Configuration
-
-```toml
-[global]
-flavor = "obsidian"
-```
-
-This allows Obsidian users to use tag syntax without triggering false 
positives from MD018.
-
 ## Automatic fixes
 
 This rule automatically adds a space after the # symbols to properly format 
the heading.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/npm/cli-darwin-arm64/package.json 
new/rumdl-0.1.61/npm/cli-darwin-arm64/package.json
--- old/rumdl-0.1.60/npm/cli-darwin-arm64/package.json  2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/npm/cli-darwin-arm64/package.json  2026-03-26 
23:27:54.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-darwin-arm64",
-  "version": "0.1.60",
+  "version": "0.1.61",
   "description": "rumdl binary for macOS ARM64 (Apple Silicon)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/npm/cli-darwin-x64/package.json 
new/rumdl-0.1.61/npm/cli-darwin-x64/package.json
--- old/rumdl-0.1.60/npm/cli-darwin-x64/package.json    2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/npm/cli-darwin-x64/package.json    2026-03-26 
23:27:54.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-darwin-x64",
-  "version": "0.1.60",
+  "version": "0.1.61",
   "description": "rumdl binary for macOS x64 (Intel)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/npm/cli-linux-arm64/package.json 
new/rumdl-0.1.61/npm/cli-linux-arm64/package.json
--- old/rumdl-0.1.60/npm/cli-linux-arm64/package.json   2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/npm/cli-linux-arm64/package.json   2026-03-26 
23:27:54.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-arm64",
-  "version": "0.1.60",
+  "version": "0.1.61",
   "description": "rumdl binary for Linux ARM64 (glibc)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/npm/cli-linux-arm64-musl/package.json 
new/rumdl-0.1.61/npm/cli-linux-arm64-musl/package.json
--- old/rumdl-0.1.60/npm/cli-linux-arm64-musl/package.json      2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/npm/cli-linux-arm64-musl/package.json      2026-03-26 
23:27:54.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-arm64-musl",
-  "version": "0.1.60",
+  "version": "0.1.61",
   "description": "rumdl binary for Linux ARM64 (musl/Alpine)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/npm/cli-linux-x64/package.json 
new/rumdl-0.1.61/npm/cli-linux-x64/package.json
--- old/rumdl-0.1.60/npm/cli-linux-x64/package.json     2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/npm/cli-linux-x64/package.json     2026-03-26 
23:27:54.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-x64",
-  "version": "0.1.60",
+  "version": "0.1.61",
   "description": "rumdl binary for Linux x64 (glibc)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/npm/cli-linux-x64-musl/package.json 
new/rumdl-0.1.61/npm/cli-linux-x64-musl/package.json
--- old/rumdl-0.1.60/npm/cli-linux-x64-musl/package.json        2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/npm/cli-linux-x64-musl/package.json        2026-03-26 
23:27:54.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-x64-musl",
-  "version": "0.1.60",
+  "version": "0.1.61",
   "description": "rumdl binary for Linux x64 (musl/Alpine)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/npm/cli-win32-x64/package.json 
new/rumdl-0.1.61/npm/cli-win32-x64/package.json
--- old/rumdl-0.1.60/npm/cli-win32-x64/package.json     2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/npm/cli-win32-x64/package.json     2026-03-26 
23:27:54.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-win32-x64",
-  "version": "0.1.60",
+  "version": "0.1.61",
   "description": "rumdl binary for Windows x64",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/npm/rumdl/package.json 
new/rumdl-0.1.61/npm/rumdl/package.json
--- old/rumdl-0.1.60/npm/rumdl/package.json     2026-03-25 11:45:42.000000000 
+0100
+++ new/rumdl-0.1.61/npm/rumdl/package.json     2026-03-26 23:27:54.000000000 
+0100
@@ -1,6 +1,6 @@
 {
   "name": "rumdl",
-  "version": "0.1.60",
+  "version": "0.1.61",
   "description": "A fast Markdown linter written in Rust",
   "license": "MIT",
   "repository": {
@@ -33,12 +33,12 @@
     "node": ">=18.0.0"
   },
   "optionalDependencies": {
-    "@rumdl/cli-darwin-x64": "0.1.60",
-    "@rumdl/cli-darwin-arm64": "0.1.60",
-    "@rumdl/cli-linux-x64": "0.1.60",
-    "@rumdl/cli-linux-arm64": "0.1.60",
-    "@rumdl/cli-linux-x64-musl": "0.1.60",
-    "@rumdl/cli-linux-arm64-musl": "0.1.60",
-    "@rumdl/cli-win32-x64": "0.1.60"
+    "@rumdl/cli-darwin-x64": "0.1.61",
+    "@rumdl/cli-darwin-arm64": "0.1.61",
+    "@rumdl/cli-linux-x64": "0.1.61",
+    "@rumdl/cli-linux-arm64": "0.1.61",
+    "@rumdl/cli-linux-x64-musl": "0.1.61",
+    "@rumdl/cli-linux-arm64-musl": "0.1.61",
+    "@rumdl/cli-win32-x64": "0.1.61"
   }
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/rumdl-0.1.60/src/rules/md018_no_missing_space_atx/md018_config.rs 
new/rumdl-0.1.61/src/rules/md018_no_missing_space_atx/md018_config.rs
--- old/rumdl-0.1.60/src/rules/md018_no_missing_space_atx/md018_config.rs       
2026-03-25 11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/src/rules/md018_no_missing_space_atx/md018_config.rs       
2026-03-26 23:27:54.000000000 +0100
@@ -11,6 +11,22 @@
     /// Default: false (all patterns are flagged)
     #[serde(default)]
     pub magiclink: bool,
+
+    /// Recognize `#word` patterns as tags instead of malformed headings.
+    /// When true, single-hash patterns like `#tag`, `#project/active` are
+    /// skipped. When null/unset, defaults to true for Obsidian flavor
+    /// and false otherwise.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub tags: Option<bool>,
+}
+
+impl MD018Config {
+    /// Whether tags mode is enabled, considering the flavor default.
+    /// Explicit config takes precedence; otherwise Obsidian flavor enables 
tags.
+    pub fn tags_enabled(&self, flavor: crate::config::MarkdownFlavor) -> bool {
+        self.tags
+            .unwrap_or(matches!(flavor, 
crate::config::MarkdownFlavor::Obsidian))
+    }
 }
 
 impl RuleConfig for MD018Config {
@@ -50,6 +66,28 @@
         let toml_str = "";
         let config: MD018Config = toml::from_str(toml_str).unwrap();
         assert!(!config.magiclink);
+        assert!(config.tags.is_none());
+    }
+
+    #[test]
+    fn test_tags_enabled() {
+        let config: MD018Config = toml::from_str("tags = true").unwrap();
+        assert_eq!(config.tags, Some(true));
+        assert!(config.tags_enabled(crate::config::MarkdownFlavor::Standard));
+    }
+
+    #[test]
+    fn test_tags_disabled() {
+        let config: MD018Config = toml::from_str("tags = false").unwrap();
+        assert_eq!(config.tags, Some(false));
+        assert!(!config.tags_enabled(crate::config::MarkdownFlavor::Obsidian));
+    }
+
+    #[test]
+    fn test_tags_default_follows_flavor() {
+        let config = MD018Config::default();
+        assert!(!config.tags_enabled(crate::config::MarkdownFlavor::Standard));
+        assert!(config.tags_enabled(crate::config::MarkdownFlavor::Obsidian));
     }
 
     #[test]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/src/rules/md018_no_missing_space_atx.rs 
new/rumdl-0.1.61/src/rules/md018_no_missing_space_atx.rs
--- old/rumdl-0.1.60/src/rules/md018_no_missing_space_atx.rs    2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/src/rules/md018_no_missing_space_atx.rs    2026-03-26 
23:27:54.000000000 +0100
@@ -19,11 +19,11 @@
 // whitespace, or punctuation (not alphanumeric continuation)
 const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
 
-// Obsidian tag pattern: #tagname, #project/active, #my-tag_2023, etc.
-// Obsidian tags start with # followed by a non-digit, non-space character,
+// Tag pattern: #tagname, #project/active, #my-tag_2023, etc.
+// Tags start with # followed by a non-digit, non-space character,
 // then any combination of word characters, hyphens, underscores, and slashes.
 // Tags cannot start with a number.
-const OBSIDIAN_TAG_PATTERN_STR: &str = r"^#[^\d\s#][^\s#]*(?:\s|$)";
+const TAG_PATTERN_STR: &str = r"^#[^\d\s#][^\s#]*(?:\s|$)";
 
 #[derive(Clone)]
 pub struct MD018NoMissingSpaceAtx {
@@ -53,10 +53,14 @@
         get_cached_regex(MAGICLINK_REF_PATTERN_STR).is_ok_and(|re| 
re.is_match(line.trim_start()))
     }
 
-    /// Check if a line is an Obsidian tag (e.g., #tagname, #project/active)
-    /// Used by Obsidian flavor to skip tag syntax
-    fn is_obsidian_tag(line: &str) -> bool {
-        get_cached_regex(OBSIDIAN_TAG_PATTERN_STR).is_ok_and(|re| 
re.is_match(line.trim_start()))
+    /// Check if a line is a tag (e.g., #tagname, #project/active)
+    fn is_tag(line: &str) -> bool {
+        get_cached_regex(TAG_PATTERN_STR).is_ok_and(|re| 
re.is_match(line.trim_start()))
+    }
+
+    /// Whether tag patterns should be recognized for the given flavor
+    fn tags_enabled(&self, flavor: MarkdownFlavor) -> bool {
+        self.config.tags_enabled(flavor)
     }
 
     /// Check if an ATX heading line is missing space after the marker
@@ -133,9 +137,9 @@
                 return None;
             }
 
-            // Obsidian flavor: skip tag syntax (#tagname, #project/active, 
etc.)
-            // Obsidian tags only use single #
-            if flavor == MarkdownFlavor::Obsidian && hash_count == 1 && 
Self::is_obsidian_tag(line) {
+            // Tags mode: skip tag syntax (#tagname, #project/active, etc.)
+            // Tags only use single #
+            if self.tags_enabled(flavor) && hash_count == 1 && 
Self::is_tag(line) {
                 return None;
             }
 
@@ -225,8 +229,8 @@
                         continue;
                     }
 
-                    // Obsidian flavor: skip tag syntax (#tagname, 
#project/active, etc.)
-                    if ctx.flavor == MarkdownFlavor::Obsidian && heading.level 
== 1 && Self::is_obsidian_tag(line) {
+                    // Tags mode: skip tag syntax (#tagname, #project/active, 
etc.)
+                    if self.tags_enabled(ctx.flavor) && heading.level == 1 && 
Self::is_tag(line) {
                         continue;
                     }
 
@@ -332,17 +336,11 @@
                     // MagicLink config: skip MagicLink-style issue/PR refs 
(#123, #10, etc.)
                     let is_magiclink = self.config.magiclink && heading.level 
== 1 && Self::is_magiclink_ref(line);
 
-                    // Obsidian flavor: skip tag syntax (#tagname, 
#project/active, etc.)
-                    let is_obsidian_tag =
-                        ctx.flavor == MarkdownFlavor::Obsidian && 
heading.level == 1 && Self::is_obsidian_tag(line);
+                    // Tags mode: skip tag syntax (#tagname, #project/active, 
etc.)
+                    let is_tag = self.tags_enabled(ctx.flavor) && 
heading.level == 1 && Self::is_tag(line);
 
                     // Only attempt fix if not a special pattern
-                    if !is_emoji
-                        && !is_unicode
-                        && !is_magiclink
-                        && !is_obsidian_tag
-                        && trimmed.len() > heading.marker.len()
-                    {
+                    if !is_emoji && !is_unicode && !is_magiclink && !is_tag && 
trimmed.len() > heading.marker.len() {
                         let after_marker = &trimmed[heading.marker.len()..];
                         if !after_marker.is_empty() && 
!after_marker.starts_with(' ') && !after_marker.starts_with('\t')
                         {
@@ -1008,7 +1006,10 @@
     #[test]
     fn test_mkdocs_magiclink_skips_numeric_refs() {
         // With magiclink config enabled, should skip MagicLink-style issue/PR 
refs (#123, #10, etc.)
-        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { 
magiclink: true });
+        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: true,
+            ..Default::default()
+        });
 
         // These numeric patterns should be SKIPPED with magiclink enabled
         assert!(
@@ -1033,7 +1034,10 @@
     #[test]
     fn test_mkdocs_magiclink_still_flags_non_numeric() {
         // With magiclink config enabled, should still flag non-numeric 
patterns
-        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { 
magiclink: true });
+        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: true,
+            ..Default::default()
+        });
 
         // Non-numeric patterns should still be flagged even with magiclink 
enabled
         assert!(
@@ -1056,7 +1060,10 @@
     #[test]
     fn test_mkdocs_magiclink_only_single_hash() {
         // MagicLink only uses single #, so ##10 should still be flagged
-        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { 
magiclink: true });
+        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: true,
+            ..Default::default()
+        });
 
         assert!(
             rule.check_atx_heading_line("##10", 
MarkdownFlavor::Standard).is_some(),
@@ -1087,7 +1094,10 @@
     #[test]
     fn test_mkdocs_magiclink_full_check() {
         // Integration test: verify magiclink config skips MagicLink refs 
through full check() flow
-        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { 
magiclink: true });
+        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: true,
+            ..Default::default()
+        });
 
         let content = r#"# PRs that are helpful for context
 
@@ -1120,7 +1130,10 @@
     #[test]
     fn test_mkdocs_magiclink_fix_exact_output() {
         // Verify fix() produces exact expected output with magiclink config
-        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { 
magiclink: true });
+        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: true,
+            ..Default::default()
+        });
 
         let content = "#10 discusses the issue.\n\n#Summary";
         let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
@@ -1137,7 +1150,10 @@
     #[test]
     fn test_mkdocs_magiclink_edge_cases() {
         // Test various edge cases for MagicLink pattern matching
-        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { 
magiclink: true });
+        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: true,
+            ..Default::default()
+        });
 
         // These should all be SKIPPED with magiclink config (valid MagicLink 
refs)
         // Note: #1 alone is skipped due to content length < 2, not MagicLink
@@ -1185,7 +1201,10 @@
     fn test_mkdocs_magiclink_hyphenated_continuation() {
         // Hyphenated patterns like #10-related should still be flagged
         // because they're likely malformed headings, not MagicLink refs
-        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { 
magiclink: true });
+        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: true,
+            ..Default::default()
+        });
 
         // Hyphen is not alphanumeric, so #10- would match as MagicLink
         // But #10-related has alphanumeric after the hyphen
@@ -1200,7 +1219,10 @@
     #[test]
     fn test_mkdocs_magiclink_standalone_number() {
         // #10 alone on a line (common in changelogs)
-        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { 
magiclink: true });
+        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: true,
+            ..Default::default()
+        });
 
         let content = "See issue:\n\n#10\n\nFor details.";
         let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
@@ -1246,7 +1268,10 @@
         let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
 
         // With magiclink: preserves #10, fixes #Summary
-        let rule_magiclink = 
MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
+        let rule_magiclink = 
MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: true,
+            ..Default::default()
+        });
         let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
         assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
 
@@ -1256,6 +1281,78 @@
         assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
     }
 
+    // ==================== Tags config tests ====================
+
+    #[test]
+    fn test_tags_config_standard_flavor() {
+        // tags = true with standard flavor should skip tag patterns
+        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: false,
+            tags: Some(true),
+        });
+
+        let content = "#tag\n\n#project/active\n\n##Introduction";
+        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+
+        let flagged_lines: Vec<usize> = result.iter().map(|w| 
w.line).collect();
+        assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags 
= true");
+        assert!(
+            !flagged_lines.contains(&3),
+            "#project/active should be skipped with tags = true"
+        );
+        assert!(flagged_lines.contains(&5), "##Introduction should still be 
flagged");
+    }
+
+    #[test]
+    fn test_tags_config_fix_standard_flavor() {
+        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: false,
+            tags: Some(true),
+        });
+
+        let content = "#tag\n\n##Introduction";
+        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
+        let fixed = rule.fix(&ctx).unwrap();
+        assert_eq!(fixed, "#tag\n\n## Introduction");
+    }
+
+    #[test]
+    fn test_tags_config_disabled_obsidian_flavor() {
+        // tags = false with Obsidian flavor should flag tag patterns
+        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
+            magiclink: false,
+            tags: Some(false),
+        });
+
+        let content = "#tag\n\n#project/active";
+        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
+        let result = rule.check(&ctx).unwrap();
+
+        assert_eq!(
+            result.len(),
+            2,
+            "tags = false should flag tag patterns even in Obsidian"
+        );
+    }
+
+    #[test]
+    fn test_tags_config_default_follows_flavor() {
+        // Unset tags should default based on flavor
+        let rule = MD018NoMissingSpaceAtx::new(); // tags: None
+
+        // Standard: should flag
+        let content = "#tag";
+        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+        assert!(!result.is_empty(), "Default standard should flag #tag");
+
+        // Obsidian: should skip
+        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
+        let result = rule.check(&ctx).unwrap();
+        assert!(result.is_empty(), "Default Obsidian should skip #tag");
+    }
+
     // ==================== Obsidian flavor tests ====================
 
     #[test]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/src/rules/md042_no_empty_links.rs 
new/rumdl-0.1.61/src/rules/md042_no_empty_links.rs
--- old/rumdl-0.1.60/src/rules/md042_no_empty_links.rs  2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/src/rules/md042_no_empty_links.rs  2026-03-26 
23:27:54.000000000 +0100
@@ -261,7 +261,8 @@
             // Check for empty destination (URL) only
             // MD042 is about links that "do not lead anywhere" - focusing on 
empty destinations
             // Empty text with valid URL is NOT flagged (that's an 
accessibility concern, not "empty link")
-            if effective_url.trim().is_empty() {
+            let trimmed_url = effective_url.trim();
+            if trimmed_url.is_empty() || trimmed_url == "#" {
                 // In MkDocs mode, check if this is an attribute anchor: []() 
followed by { #anchor }
                 if mkdocs_mode
                     && link.text.trim().is_empty()
@@ -655,6 +656,68 @@
     }
 
     #[test]
+    fn test_bare_hash_treated_as_empty_url() {
+        let rule = MD042NoEmptyLinks::new();
+
+        // [](#) - bare fragment marker with no name is an empty/meaningless 
URL
+        let ctx = LintContext::new("# Title\n\n[](#)\n", 
crate::config::MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+        assert_eq!(
+            result.len(),
+            1,
+            "[](#) should be flagged as empty link. Got: {result:?}"
+        );
+        assert!(result[0].message.contains("[](#)"));
+
+        // [text](#) - text with bare # URL
+        let ctx = LintContext::new("# Title\n\n[text](#)\n", 
crate::config::MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+        assert_eq!(
+            result.len(),
+            1,
+            "[text](#) should be flagged as empty link. Got: {result:?}"
+        );
+        assert!(result[0].message.contains("[text](#)"));
+
+        // [text]( # ) - bare # with surrounding whitespace
+        let ctx = LintContext::new(
+            "# Title\n\n[text]( # )\n",
+            crate::config::MarkdownFlavor::Standard,
+            None,
+        );
+        let result = rule.check(&ctx).unwrap();
+        assert_eq!(
+            result.len(),
+            1,
+            "[text]( # ) should be flagged as empty link. Got: {result:?}"
+        );
+
+        // [text](#foo) - actual fragment should NOT be flagged
+        let ctx = LintContext::new(
+            "# Title\n\n[text](#foo)\n",
+            crate::config::MarkdownFlavor::Standard,
+            None,
+        );
+        let result = rule.check(&ctx).unwrap();
+        assert!(
+            result.is_empty(),
+            "[text](#foo) has a real fragment, should NOT be flagged. Got: 
{result:?}"
+        );
+
+        // [](#section) - empty text but valid fragment URL should NOT be 
flagged
+        let ctx = LintContext::new(
+            "# Title\n\n[](#section)\n",
+            crate::config::MarkdownFlavor::Standard,
+            None,
+        );
+        let result = rule.check(&ctx).unwrap();
+        assert!(
+            result.is_empty(),
+            "[](#section) has a real URL, should NOT be flagged. Got: 
{result:?}"
+        );
+    }
+
+    #[test]
     fn test_reference_link_with_undefined_reference() {
         // Undefined references are handled by MD052, not MD042
         // MD042 should NOT flag [text][undefined] - it's not an "empty link"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/rumdl-0.1.60/src/rules/md063_heading_capitalization/mod.rs 
new/rumdl-0.1.61/src/rules/md063_heading_capitalization/mod.rs
--- old/rumdl-0.1.60/src/rules/md063_heading_capitalization/mod.rs      
2026-03-25 11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/src/rules/md063_heading_capitalization/mod.rs      
2026-03-26 23:27:54.000000000 +0100
@@ -939,6 +939,11 @@
                     continue;
                 }
 
+                // Skip invalid headings (e.g., `#tag` which lacks required 
space after #)
+                if !heading.is_valid {
+                    continue;
+                }
+
                 // Apply capitalization and compare
                 let original_text = &heading.raw_text;
                 let fixed_text = self.apply_capitalization(original_text);
@@ -1001,6 +1006,11 @@
                     continue;
                 }
 
+                // Skip invalid headings (e.g., `#tag` which lacks required 
space after #)
+                if !heading.is_valid {
+                    continue;
+                }
+
                 let original_text = &heading.raw_text;
                 let fixed_text = self.apply_capitalization(original_text);
 
@@ -1253,6 +1263,46 @@
 
     // Acronym preservation tests
     #[test]
+    fn test_skip_obsidian_tags_not_headings() {
+        let rule = create_rule();
+
+        // #tag (no space after #) is an Obsidian tag, not a heading
+        let content = "# H1\n\n#tag\n";
+        let ctx = LintContext::new(content, 
crate::config::MarkdownFlavor::Obsidian, None);
+        let result = rule.check(&ctx).unwrap();
+        assert!(
+            result.is_empty() || result.iter().all(|w| w.line != 3),
+            "Obsidian tag #tag should not be treated as a heading: {result:?}"
+        );
+    }
+
+    #[test]
+    fn test_skip_invalid_atx_headings_no_space() {
+        let rule = create_rule();
+
+        // #NoSpace is not a valid ATX heading (requires space after #)
+        let content = "#notaheading\n";
+        let ctx = LintContext::new(content, 
crate::config::MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+        assert!(
+            result.is_empty(),
+            "Invalid ATX heading without space should not be flagged: 
{result:?}"
+        );
+    }
+
+    #[test]
+    fn test_fix_skips_obsidian_tags() {
+        let rule = create_rule();
+
+        let content = "# hello world\n\n#tag\n";
+        let ctx = LintContext::new(content, 
crate::config::MarkdownFlavor::Obsidian, None);
+        let fixed = rule.fix(&ctx).unwrap();
+        // Should fix the real heading but leave the tag alone
+        assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag 
#tag");
+        assert!(fixed.contains("# Hello World"), "Fix should still fix real 
headings");
+    }
+
+    #[test]
     fn test_preserve_all_caps_acronyms() {
         let rule = create_rule();
         let ctx = |c| LintContext::new(c, 
crate::config::MarkdownFlavor::Standard, None);
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/src/utils/text_reflow.rs 
new/rumdl-0.1.61/src/utils/text_reflow.rs
--- old/rumdl-0.1.60/src/utils/text_reflow.rs   2026-03-25 11:45:42.000000000 
+0100
+++ new/rumdl-0.1.61/src/utils/text_reflow.rs   2026-03-26 23:27:54.000000000 
+0100
@@ -92,6 +92,67 @@
     }
 }
 
+/// Build a boolean mask indicating which character positions are inside 
inline code spans.
+/// Handles single, double, and triple backtick delimiters.
+fn compute_inline_code_mask(text: &str) -> Vec<bool> {
+    let chars: Vec<char> = text.chars().collect();
+    let len = chars.len();
+    let mut mask = vec![false; len];
+    let mut i = 0;
+
+    while i < len {
+        if chars[i] == '`' {
+            // Count opening backticks
+            let open_start = i;
+            let mut backtick_count = 0;
+            while i < len && chars[i] == '`' {
+                backtick_count += 1;
+                i += 1;
+            }
+
+            // Find matching closing backticks (same count)
+            let mut found_close = false;
+            let content_start = i;
+            while i < len {
+                if chars[i] == '`' {
+                    let close_start = i;
+                    let mut close_count = 0;
+                    while i < len && chars[i] == '`' {
+                        close_count += 1;
+                        i += 1;
+                    }
+                    if close_count == backtick_count {
+                        // Mark the content between the delimiters (not the 
backticks themselves)
+                        for item in 
mask.iter_mut().take(close_start).skip(content_start) {
+                            *item = true;
+                        }
+                        // Also mark the opening and closing backticks
+                        for item in 
mask.iter_mut().take(content_start).skip(open_start) {
+                            *item = true;
+                        }
+                        for item in mask.iter_mut().take(i).skip(close_start) {
+                            *item = true;
+                        }
+                        found_close = true;
+                        break;
+                    }
+                } else {
+                    i += 1;
+                }
+            }
+
+            if !found_close {
+                // No matching close — backticks are literal, not code span
+                i = open_start + backtick_count;
+            }
+        } else {
+            i += 1;
+        }
+    }
+
+    mask
+}
+
 /// Detect if a character position is a sentence boundary
 /// Based on the approach from github.com/JoshuaKGoldberg/sentences-per-line
 /// Supports both ASCII punctuation (. ! ?) and CJK punctuation (。 ! ?)
@@ -275,6 +336,9 @@
     abbreviations: &HashSet<String>,
     require_sentence_capital: bool,
 ) -> Vec<String> {
+    // Pre-compute which character positions are inside inline code spans
+    let in_code = compute_inline_code_mask(text);
+
     let mut sentences = Vec::new();
     let mut current_sentence = String::new();
     let mut chars = text.chars().peekable();
@@ -283,7 +347,7 @@
     while let Some(c) = chars.next() {
         current_sentence.push(c);
 
-        if is_sentence_boundary(text, pos, abbreviations, 
require_sentence_capital) {
+        if !in_code[pos] && is_sentence_boundary(text, pos, abbreviations, 
require_sentence_capital) {
             // Consume any trailing emphasis/strikethrough markers and quotes 
(they belong to the current sentence)
             while let Some(&next) = chars.peek() {
                 if next == '*' || next == '_' || next == '~' || 
is_closing_quote(next) {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/tests/cli/cli_flavor_test.rs 
new/rumdl-0.1.61/tests/cli/cli_flavor_test.rs
--- old/rumdl-0.1.60/tests/cli/cli_flavor_test.rs       2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/tests/cli/cli_flavor_test.rs       2026-03-26 
23:27:54.000000000 +0100
@@ -428,6 +428,118 @@
     );
 }
 
+/// End-to-end test: MD018 tags config enables tag recognition without 
Obsidian flavor
+#[test]
+fn test_md018_tags_config_standard_flavor() {
+    let temp_dir = tempdir().unwrap();
+
+    // Create config with tags enabled (no Obsidian flavor)
+    let config_content = r#"
+[MD018]
+tags = true
+"#;
+    fs::write(temp_dir.path().join(".rumdl.toml"), config_content).unwrap();
+
+    // Create markdown with tag patterns and malformed headings
+    let md_content = r#"# Real Heading
+
+#todo this is a tag
+
+#project/active nested tag
+
+##Introduction
+
+#123
+"#;
+    fs::write(temp_dir.path().join("test.md"), md_content).unwrap();
+
+    // Run with tags config - should skip tags, flag ##Introduction and #123
+    let (success, stdout, _stderr) = run_rumdl(temp_dir.path(), &["check", 
"test.md"]);
+    assert!(!success, "Should find issues (##Introduction, #123)");
+
+    let md018_count = stdout.matches("MD018").count();
+    assert_eq!(
+        md018_count, 2,
+        "With tags=true, should flag exactly 2 MD018 issues (##Introduction, 
#123). Found {md018_count}. stdout: {stdout}"
+    );
+
+    // Tags should NOT be flagged
+    assert!(
+        !stdout.contains("test.md:3:"),
+        "#todo (line 3) should NOT be flagged with tags=true. stdout: {stdout}"
+    );
+    assert!(
+        !stdout.contains("test.md:5:"),
+        "#project/active (line 5) should NOT be flagged with tags=true. 
stdout: {stdout}"
+    );
+}
+
+/// End-to-end test: MD018 tags=false overrides Obsidian flavor default
+#[test]
+fn test_md018_tags_config_override_obsidian() {
+    let temp_dir = tempdir().unwrap();
+
+    // Create config with Obsidian flavor but tags explicitly disabled
+    let config_content = r#"
+[global]
+flavor = "obsidian"
+
+[MD018]
+tags = false
+"#;
+    fs::write(temp_dir.path().join(".rumdl.toml"), config_content).unwrap();
+
+    let md_content = r#"# Real Heading
+
+#todo
+
+#project/active
+"#;
+    fs::write(temp_dir.path().join("test.md"), md_content).unwrap();
+
+    // With tags=false, should flag tag patterns even in Obsidian flavor
+    let (success, stdout, _stderr) = run_rumdl(temp_dir.path(), &["check", 
"test.md"]);
+    assert!(!success, "Should find issues with tags=false");
+
+    let md018_count = stdout.matches("MD018").count();
+    assert_eq!(
+        md018_count, 2,
+        "With tags=false in Obsidian flavor, should flag tag patterns. Found 
{md018_count}. stdout: {stdout}"
+    );
+}
+
+/// End-to-end test: MD018 tags config fix preserves tags
+#[test]
+fn test_md018_tags_config_fix_preserves_tags() {
+    let temp_dir = tempdir().unwrap();
+
+    let config_content = r#"
+[MD018]
+tags = true
+"#;
+    fs::write(temp_dir.path().join(".rumdl.toml"), config_content).unwrap();
+
+    let md_content = "#todo\n\n#Summary\n";
+    let md_path = temp_dir.path().join("test.md");
+    fs::write(&md_path, md_content).unwrap();
+
+    let (success, _stdout, stderr) = run_rumdl(temp_dir.path(), &["check", 
"--fix", "test.md"]);
+    assert!(success, "Fix command should succeed. stderr: {stderr}");
+
+    let fixed_content = fs::read_to_string(&md_path).expect("Should read fixed 
file");
+
+    // Both #todo and #Summary match the tag pattern (# + non-digit non-space),
+    // so neither should be modified
+    assert!(
+        fixed_content.contains("#todo"),
+        "#todo should be preserved with tags=true. Fixed content: 
{fixed_content}"
+    );
+    assert!(
+        fixed_content.contains("#Summary"),
+        "#Summary should be preserved with tags=true (matches tag pattern). 
Fixed content: {fixed_content}"
+    );
+}
+
 /// Regression test: Fix coordination must respect per-file-flavor 
configuration.
 ///
 /// Bug: FixCoordinator used config.markdown_flavor() (global) instead of
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.60/tests/formats/sentence_per_line_test.rs 
new/rumdl-0.1.61/tests/formats/sentence_per_line_test.rs
--- old/rumdl-0.1.60/tests/formats/sentence_per_line_test.rs    2026-03-25 
11:45:42.000000000 +0100
+++ new/rumdl-0.1.61/tests/formats/sentence_per_line_test.rs    2026-03-26 
23:27:54.000000000 +0100
@@ -47,6 +47,51 @@
 }
 
 #[test]
+fn test_inline_code_punctuation_not_sentence_boundary() {
+    let rule = create_sentence_per_line_rule();
+
+    // Punctuation inside inline code should not be treated as a sentence 
boundary
+    let content = "Rust macros look like `foo! {}` with the exclamation 
mark.\n";
+    let ctx = LintContext::new(content, 
rumdl_lib::config::MarkdownFlavor::Standard, None);
+    let result = rule.check(&ctx).unwrap();
+
+    assert!(
+        result.is_empty(),
+        "Punctuation inside inline code should not split sentences: {result:?}"
+    );
+}
+
+#[test]
+fn test_inline_code_with_period_not_sentence_boundary() {
+    let rule = create_sentence_per_line_rule();
+
+    // Period inside inline code should not be treated as a sentence boundary
+    let content = "Use `file.txt` as the input file for testing.\n";
+    let ctx = LintContext::new(content, 
rumdl_lib::config::MarkdownFlavor::Standard, None);
+    let result = rule.check(&ctx).unwrap();
+
+    assert!(
+        result.is_empty(),
+        "Period inside inline code should not split sentences: {result:?}"
+    );
+}
+
+#[test]
+fn test_inline_code_with_question_mark_not_sentence_boundary() {
+    let rule = create_sentence_per_line_rule();
+
+    // Question mark inside inline code should not be treated as a sentence 
boundary
+    let content = "The regex `is_valid?` matches optional characters.\n";
+    let ctx = LintContext::new(content, 
rumdl_lib::config::MarkdownFlavor::Standard, None);
+    let result = rule.check(&ctx).unwrap();
+
+    assert!(
+        result.is_empty(),
+        "Question mark inside inline code should not split sentences: 
{result:?}"
+    );
+}
+
+#[test]
 fn test_abbreviations_not_split() {
     let rule = create_sentence_per_line_rule();
     let content = "Mr. Smith met Dr. Jones at 3.14 PM.";

++++++ rumdl.obsinfo ++++++
--- /var/tmp/diff_new_pack.7dGC65/_old  2026-03-27 16:53:40.957934933 +0100
+++ /var/tmp/diff_new_pack.7dGC65/_new  2026-03-27 16:53:40.965935268 +0100
@@ -1,5 +1,5 @@
 name: rumdl
-version: 0.1.60
-mtime: 1774435542
-commit: 9ab9e256cf266908555448f396c725d50c103919
+version: 0.1.61
+mtime: 1774564074
+commit: 9ce5d163deb00a519597e730f3981c33219752d1
 

++++++ vendor.tar.zst ++++++
/work/SRC/openSUSE:Factory/rumdl/vendor.tar.zst 
/work/SRC/openSUSE:Factory/.rumdl.new.8177/vendor.tar.zst differ: char 7, line 1

Reply via email to