Merge pull request #5 from apache/feature/python-license-checker-with-pr-fix

feat: add Python pre-build license checker with --fix and --pr
diff --git a/.gitignore b/.gitignore
index 0fa061f..7930ee5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,7 @@
 target
 
 .idea
+
+# Python
+__pycache__/
+*.pyc
diff --git a/README.md b/README.md
index 4ca123f..5e03760 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,300 @@
-## Introduce
-License check required for shenyu release.
+# Apache ShenYu Watcher
 
-## Function
+License check tool for Apache ShenYu releases. Ensures every runtime dependency is properly declared in the project's LICENSE file.
 
-Support jar to scan if the LICENSE file matches the jar under the lib folder.
+## Overview
 
-## Use
+Two tools are provided:
 
-```shell
-java -jar shenyu-watcher filePath/xxx.tar.gz 
+| Tool | Language | When to use | Dependencies |
+|---|---|---|---|
+| `shenyu_watcher.py` | Python 3 | **Before packaging** — check directly from Maven project | Maven (optional, falls back to pom.xml parsing) |
+| `ShenyuWatcher.java` | Java 8 | After packaging — check from a `.tar.gz` release archive | Python ≥ 3.8, Java 8 |
+
+The Python tool is recommended for development and CI workflows — it catches missing license declarations **before** you build, without needing to create a release archive.
+
+---
+
+## Python Tool (`shenyu_watcher.py`)
+
+### Features
+
+- **Pre-build checking** — resolves all runtime dependencies (including transitive) via `mvn dependency:list`, no packaging needed
+- **Auto-fix mode** (`--fix`) — fetches license info from Maven Central POMs and appends missing entries to the LICENSE file in the correct section
+- **PR mode** (`--pr`) — after fixing, creates a git branch, commits only the LICENSE change, pushes, and opens a GitHub PR via the `gh` CLI (implies `--fix`)
+- **Smart LICENSE discovery** — auto-locates LICENSE files in `LICENSE`, `src/main/release-docs/LICENSE`, etc.
+- **Classifier support** — correctly handles Maven classifiers (e.g., `shiro-core:jakarta:1.13.0`, `netty-tcnative-boringssl-static:linux-x86_64:2.0.65.Final`)
+- **Precise matching** — literal string matching (not regex), avoids false positives from license boilerplate text
+- **Fork detection** — when a POM's GitHub URL points to a personal fork rather than the canonical upstream, falls back to mvnrepository for accurate attribution
+- **Lib-directory scanning** — for distribution modules that Maven can't resolve (e.g. pom-packaged aggregators depending on uninstalled reactor siblings), reads the exact shipped jar set from an assembled `lib/` directory instead. GAV is read from each jar's embedded `pom.properties`, with filename parsing as a fallback.
+- **Fallback** — works without Maven by parsing pom.xml directly (direct dependencies only)
+- **Zero dependencies** — pure Python standard library, no pip install needed
+
+### Requirements
+
+- Python 3.6+
+- Maven 3.x (recommended; falls back to pom.xml parsing if unavailable). Not required when scanning an assembled `lib/` directory.
+- GitHub CLI (`gh`) installed and authenticated, when using `--pr`.
+
+### Usage
+
+```bash
+# Check only — report missing dependencies.
+# An assembled lib/ dir is auto-detected under <project>/target/*-bin/lib and
+# scanned in preference to running Maven; otherwise Maven is used.
+python shenyu_watcher.py /path/to/maven/project
+
+# Check with explicit LICENSE path
+python shenyu_watcher.py /path/to/maven/project /path/to/LICENSE
+
+# Check and auto-fix — append missing entries to LICENSE
+python shenyu_watcher.py --fix /path/to/maven/project
+
+# Short form
+python shenyu_watcher.py -f /path/to/maven/project
+
+# Scan a specific lib/ directory of jars instead of running Maven
+python shenyu_watcher.py --lib /path/to/lib /path/to/maven/project
+
+# Force Maven dependency resolution (disable lib/ auto-detection)
+python shenyu_watcher.py --no-lib /path/to/maven/project
+
+# Fix and open a GitHub PR for the LICENSE change (implies --fix)
+python shenyu_watcher.py --pr /path/to/maven/project
+
+# Fix + PR with a custom branch and base
+python shenyu_watcher.py --pr --branch fix/admin-license --base main /path/to/maven/project
+
+# Fix + PR pushing to a fork remote (when you lack push access to origin)
+python shenyu_watcher.py --pr --remote fork /path/to/maven/project
 ```
 
+### Examples
 
+Check the shenyu-admin-dist module:
+
+```bash
+python shenyu_watcher.py /path/to/shenyu/shenyu-dist/shenyu-admin-dist
+```
+
+Output (the dist module's assembled `lib/` is auto-detected and scanned):
+
+```
+šŸ“¦ Scanning JARs in lib directory...
+   Found 324 unique dependencies (214 from pom.properties, 110 from filename).
+   (110 jars had no pom.properties — resolved from filename, groupId unknown; license auto-fix will skip these.)
+šŸ“„ Using LICENSE: .../shenyu-admin-dist/src/main/release-docs/LICENSE
+
+============================================================
+  License Check Report
+============================================================
+
+  Total dependencies:      324
+  Checked:                 294
+  Skipped (project):       30
+  Skipped (test/provided): 0
+  Matched in LICENSE:       290
+  NOT matched in LICENSE:    4
+
+āŒ 4 dependency(ies) NOT declared in LICENSE
+   (grouped by groupId, 3 groups)
+────────────────────────────────────────────────────────────
+   (2 artifact(s))
+    • jna:5.18.1
+    • jna-platform:5.18.1
+  com.github.oshi (1 artifact(s))
+    • oshi-core:6.10.0
+  jakarta.mail (1 artifact(s))
+    • jakarta.mail-api:2.1.3
+
+Result: FAIL — 4 dependency(ies) missing from LICENSE
+```
+
+> `jna` and `jna-platform` show no groupId because those jars had no embedded
+> `pom.properties` and were resolved from their filenames. License **matching**
+> still works for them (by artifactId + version). For `--fix`, the groupId is
+> recovered from Maven Central (by artifactId + version) so the license can be
+> fetched; if that lookup also fails, the dependency is skipped with a warning.
+
+Auto-fix and re-verify:
+
+```bash
+python shenyu_watcher.py --fix /path/to/shenyu/shenyu-dist/shenyu-admin-dist
+```
+
+```
+šŸ” Resolving license info for 4 missing dependencies...
+   jna 5.18.1: groupId resolved from Maven Central -> net.java.dev.jna
+   jna 5.18.1 → LGPL licenses (LGPL-2.1-or-later)
+   jna-platform 5.18.1: groupId resolved from Maven Central -> net.java.dev.jna
+   jna-platform 5.18.1 → LGPL licenses (LGPL-2.1-or-later)
+   oshi-core 6.10.0 → MIT licenses (MIT)
+   jakarta.mail-api 2.1.3 → EPL licenses (EPL 2.0)
+
+āœ… Added 4 entries to LICENSE
+
+Added entries by section:
+  EPL licenses:
+    + jakarta.mail-api 2.1.3: https://mvnrepository.com/artifact/jakarta.mail/jakarta.mail-api, EPL 2.0
+  LGPL licenses:
+    + jna 5.18.1: https://mvnrepository.com/artifact/net.java.dev.jna/jna, LGPL-2.1-or-later
+    + jna-platform 5.18.1: https://mvnrepository.com/artifact/net.java.dev.jna/jna-platform, LGPL-2.1-or-later
+  MIT licenses:
+    + oshi-core 6.10.0: https://github.com/oshi/oshi, MIT
+
+============================================================
+  Re-checking after fix...
+============================================================
+```
+
+### How `--fix` Works
+
+1. Runs the normal check to find unmatched dependencies
+2. For each unmatched dependency, fetches its POM from Maven Central
+   - Resolves parent POMs recursively (up to 3 levels) since license info is often in parent POMs
+   - Extracts `<license><name>`, `<url>`, and `<scm><url>` from the POM
+   - Uses `<url>` (project homepage) over `<scm><url>` (may point to a personal fork)
+   - Detects GitHub fork URLs and falls back to mvnrepository for accurate attribution
+   - For dependencies resolved from filename only (no groupId), the groupId is recovered from Maven Central by artifactId + version before fetching the POM; if that lookup fails, the dependency is skipped with a warning
+3. Maps the POM license name to the correct LICENSE section header (e.g., "Apache License, Version 2.0" → "Apache 2.0 licenses")
+4. Appends the entry in the format `    artifactId version: url, LicenseName` to the correct section
+5. Re-runs the check to verify all entries now pass
+
+With `--pr`, the flow additionally:
+
+6. **Before** writing to disk, creates a new git branch (auto-named `license-fix-<timestamp>`, or the name passed to `--branch`) from the current HEAD — so the only commit on the new branch is the LICENSE change
+7. Writes the LICENSE change, then commits **only the LICENSE path** (`git commit -- <relpath>`) — unrelated dirty files in the working tree are never swept in
+8. Pushes the branch to `--remote` (default `origin`) with upstream tracking
+9. Runs `gh pr create` with a title and a body listing the added entries and the check context
+10. If no entries were actually added (all duplicates or all skipped), the branch is rolled back and **no PR is opened**
+
+### Opening a PR with `--pr`
+
+`--pr` turns the in-place LICENSE edit into a reviewable pull request. It implies `--fix`, so you can run it directly:
+
+```bash
+python shenyu_watcher.py --pr /path/to/shenyu/shenyu-dist/shenyu-admin-dist
+```
+
+What it does:
+
+- **Auto-detects the git root** from the project directory (`git rev-parse --show-toplevel`), so it works when the project is a subdirectory of a larger repo (e.g. a `shenyu` monorepo module whose LICENSE lives at `src/main/release-docs/LICENSE`).
+- **Commits only the LICENSE file** — a path-scoped commit means build artifacts or other uncommitted changes in the working tree are left alone. It only requires the LICENSE file itself to be clean beforehand.
+- **Pushes and opens the PR** via `gh`. The PR body lists each added entry (grouped by LICENSE section) plus the check totals and any dependencies still unmatched after the fix (for manual review).
+
+Pre-flight checks (exit code `2` on failure): the project must be inside a git repository; the `--remote` (default `origin`) must exist; the LICENSE file must be inside the repo and not already have uncommitted changes; `gh` must be installed and authenticated.
+
+If you lack push access to `origin`, fork the repo (`gh repo fork --remote --remote-name fork`) and re-run with `--remote fork`.
+
+Example output:
+
+```
+šŸ”§ PR target: /path/to/shenyu
+   branch: license-fix-20260708-143012 (from main)  remote: origin  base: <repo default>
+
+āœ… Pull request opened: https://github.com/apache/shenyu/pull/NNNN
+   branch: license-fix-20260708-143012  base: <repo default>
+```
+
+### Fork Detection
+
+When a POM's `<url>` or `<scm><url>` points to a GitHub repository that appears to be a personal fork (e.g., `at.yawk.lz4:lz4-java` has `https://github.com/yawkat/lz4-java`), the tool detects this and uses `https://mvnrepository.com/artifact/...` instead, which always links to the canonical project information. The heuristic checks whether the GitHub owner matches the groupId or artifactId — if not, it's treated as a likely fork.
+
+### License Section Mapping
+
+The tool recognizes these section headers in the LICENSE file:
+
+| POM License Name Contains | LICENSE Section |
+|---|---|
+| `Apache` | Apache 2.0 licenses |
+| `MIT` | MIT licenses |
+| `BSD` / `Go License` | BSD licenses |
+| `EPL` / `Eclipse Public License` | EPL licenses |
+| `EDL` / `Eclipse Distribution License` | EDL License |
+| `MPL` / `Mozilla Public License` | MPL licenses |
+| `CDDL` | CDDL licenses |
+| `CC0` | CC0 licenses |
+| `Public Domain` | Public Domain licenses |
+| `Bouncy Castle` | Bouncy Castle licenses |
+| `ISC` | ISC licenses |
+| Unknown āš ļø | Apache 2.0 licenses (with warning) |
+
+When a license name cannot be mapped to any known section, the tool prints a warning and defaults to Apache 2.0 — review these entries manually.
+
+### Entry Format
+
+Each appended entry follows the format used by the existing LICENSE file:
+
+```
+    <artifactId> <version>[-<classifier>]: <url>, <LicenseName>
+```
+
+Examples:
+- `zookeeper 3.9.5: https://gitbox.apache.org/repos/asf/zookeeper, Apache 2.0`
+- `oshi-core 6.7.0: https://github.com/oshi/oshi, MIT`
+- `bcprov-jdk18on 1.84: https://github.com/bcgit/bc-java, Bouncy Castle`
+- `shiro-core 1.13.0-jakarta: https://shiro.apache.org, Apache 2.0` (with classifier)
+
+### Exit Codes
+
+| Code | Meaning |
+|---|---|
+| 0 | All dependencies matched (PASS) |
+| 1 | Missing dependencies found (FAIL), or Maven/LICENSE not found |
+| 2 | `--pr` requested but preconditions failed (not a git repo, `gh` missing/unauthenticated, LICENSE dirty or outside repo, push failed) — see message |
+
+### CI Integration
+
+```bash
+# In CI pipeline — exit code 1 if any dependency is missing
+python shenyu_watcher.py /path/to/project
+
+# Auto-fix in CI (modifies LICENSE in place — review the diff in version control)
+python shenyu_watcher.py --fix /path/to/project
+```
+
+---
+
+## Java Tool (`ShenyuWatcher.java`)
+
+### Build
+
+```bash
+mvn clean package
+```
+
+### Use
+
+```bash
+java -jar target/shenyu-watcher-1.0-SNAPSHOT.jar filePath/xxx.tar.gz
+```
+
+Checks a `.tar.gz` release archive: extracts it, scans `lib/` for JAR filenames, parses each into package name + version, and verifies against the LICENSE file inside the archive.
+
+### Limitations
+
+- Requires Python ≥ 3.8 installed (checked at startup but not actually used)
+- JAR filename parsing uses a regex that can fail for artifact IDs containing digits (e.g., `commons-lang3-3.14.0.jar`)
+- Uses regex matching for LICENSE checks — `.` in version strings matches any character, causing potential false matches
+- No transitive dependency resolution — only sees what's physically in the `lib/` folder
+- No auto-fix capability
+
+---
+
+## Comparison
+
+| Feature | Python (`shenyu_watcher.py`) | Java (`ShenyuWatcher.java`) |
+|---|---|---|
+| Check timing | **Before packaging** | After packaging |
+| Dependency source | `mvn dependency:list` (all runtime deps) | JAR filenames in `lib/` |
+| Transitive deps | āœ… Yes | āŒ Only what's in `lib/` |
+| JAR name parsing | Not needed — Maven provides exact coordinates | Regex-based, breaks on names like `commons-lang3` |
+| LICENSE matching | Literal string match (no false positives) | Regex match (`.` matches any char) |
+| Classifier support | āœ… Handles `shiro-core:jakarta`, native variants | āŒ Not supported |
+| Auto-fix | āœ… `--fix` flag | āŒ None |
+| Open PR | āœ… `--pr` (via `gh`) | āŒ None |
+| License discovery | āœ… Auto-finds LICENSE in project | āŒ Must be in tar.gz |
+| Fork detection | āœ… Detects fork POMs, uses mvnrepository fallback | āŒ N/A |
+| Unknown license warning | āœ… Warns on unmappable licenses | āŒ N/A |
+| Python dependency | None | Python ≥ 3.8 (checked but unused) |
+| Maven dependency | Recommended (falls back to pom.xml) | None |
diff --git a/pom.xml b/pom.xml
index 8ee6924..7926502 100644
--- a/pom.xml
+++ b/pom.xml
@@ -37,6 +37,12 @@
             <artifactId>commons-io</artifactId>
             <version>2.11.0</version>
         </dependency>
+        
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-collections4</artifactId>
+            <version>4.4</version>
+        </dependency>
 
 
     </dependencies>
diff --git a/shenyu_watcher.py b/shenyu_watcher.py
new file mode 100644
index 0000000..eaf75ff
--- /dev/null
+++ b/shenyu_watcher.py
@@ -0,0 +1,1853 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""
+shenyu-watcher-python: Pre-build dependency license checker.
+
+Checks that every Maven dependency (including transitive) is properly
+declared in the project's LICENSE file — without needing to build or
+package the project first.
+
+Usage:
+    python shenyu_watcher.py /path/to/maven/project [/path/to/LICENSE]
+
+If LICENSE path is omitted, it defaults to <project>/LICENSE.
+"""
+
+import os
+import re
+import subprocess
+import sys
+import glob
+import zipfile
+import datetime
+import urllib.request
+import urllib.error
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional, Tuple
+
+
+# ---------------------------------------------------------------------------
+# Data classes
+# ---------------------------------------------------------------------------
+
+@dataclass
+class Dependency:
+    """A Maven dependency with resolved coordinates."""
+    group_id: str
+    artifact_id: str
+    version: str
+    classifier: str = ""  # e.g. "linux-x86_64", "jakarta"
+    scope: str = ""
+
+    @property
+    def coordinate(self) -> str:
+        if self.classifier:
+            return f"{self.group_id}:{self.artifact_id}:{self.version}:{self.classifier}"
+        return f"{self.group_id}:{self.artifact_id}:{self.version}"
+
+    @property
+    def jar_name(self) -> str:
+        if self.classifier:
+            return f"{self.artifact_id}-{self.version}-{self.classifier}.jar"
+        return f"{self.artifact_id}-{self.version}.jar"
+
+    def __hash__(self):
+        return hash((self.group_id, self.artifact_id, self.version, self.classifier))
+
+    def __eq__(self, other):
+        if not isinstance(other, Dependency):
+            return False
+        return (self.group_id == other.group_id
+                and self.artifact_id == other.artifact_id
+                and self.version == other.version
+                and self.classifier == other.classifier)
+
+
+@dataclass
+class FixEntry:
+    """A single entry written to the LICENSE file by the fix action."""
+    section: str          # LICENSE section header, e.g. "MIT licenses"
+    dep: Dependency
+    entry_line: str       # the formatted line written to LICENSE
+    license_name: str     # display license name from the POM
+
+
+@dataclass
+class CheckResult:
+    """Result of a license check run."""
+    total: int = 0
+    checked: int = 0
+    skipped_project: int = 0
+    matched: List[Dependency] = field(default_factory=list)
+    unmatched: List[Dependency] = field(default_factory=list)
+    skipped_scope: List[Dependency] = field(default_factory=list)
+    parse_failures: List[str] = field(default_factory=list)
+
+
+# ---------------------------------------------------------------------------
+# Maven dependency resolution
+# ---------------------------------------------------------------------------
+
+def _find_maven() -> str:
+    """Locate the mvn executable."""
+    # 1. Check common wrapper locations
+    for wrapper in ("./mvnw", "mvnw"):
+        if os.path.isfile(wrapper) and os.access(wrapper, os.X_OK):
+            return wrapper
+
+    # 2. Check PATH via shutil.which
+    from shutil import which
+    found = which("mvn")
+    if found:
+        return found
+
+    # 3. Probe common installation paths (subprocess can't see shell functions)
+    candidates = [
+        os.path.expanduser("~/.local/share/maven-no-jansi/libexec/bin/mvn"),
+        os.path.expanduser("~/apache-maven/bin/mvn"),
+        "/usr/local/bin/mvn",
+        "/opt/homebrew/bin/mvn",
+        "/usr/bin/mvn",
+    ]
+    for path in candidates:
+        if os.path.isfile(path) and os.access(path, os.X_OK):
+            return path
+
+    # 4. Try M2_HOME / MAVEN_HOME env vars
+    for env_var in ("M2_HOME", "MAVEN_HOME"):
+        home = os.environ.get(env_var)
+        if home:
+            mvn_bin = os.path.join(home, "bin", "mvn")
+            if os.path.isfile(mvn_bin) and os.access(mvn_bin, os.X_OK):
+                return mvn_bin
+
+    # 5. Fallback — let subprocess try and produce a clear error
+    return "mvn"
+
+
+def _run_maven_dependency_list(project_dir: str) -> str:
+    """Run `mvn dependency:list` and return the output."""
+    mvn = _find_maven()
+
+    cmd = [
+        mvn,
+        "dependency:list",
+        "-DincludeScope=runtime",
+        "-f", project_dir,
+    ]
+    try:
+        result = subprocess.run(
+            cmd, capture_output=True, text=True, timeout=300
+        )
+    except FileNotFoundError:
+        print(f"āŒ Maven not found at '{mvn}'. Install Maven or add mvnw to the project.", file=sys.stderr)
+        sys.exit(1)
+    except subprocess.TimeoutExpired:
+        print("āŒ Maven dependency resolution timed out (5 min).", file=sys.stderr)
+        sys.exit(1)
+
+    if result.returncode != 0:
+        print(f"āŒ Maven failed (exit {result.returncode}):", file=sys.stderr)
+        # Maven writes its [ERROR] diagnostics to STDOUT, not stderr — print both
+        # so the actual failure reason is visible (stderr is often empty).
+        if result.stdout:
+            print(result.stdout, file=sys.stderr)
+        if result.stderr:
+            print(result.stderr, file=sys.stderr)
+        sys.exit(1)
+
+    return result.stdout
+
+
+def _parse_dependency_list(output: str) -> List[Dependency]:
+    """
+    Parse `mvn dependency:list` output.
+
+    Maven outputs lines in two formats depending on whether a classifier exists:
+
+    Without classifier:
+        [INFO]    groupId:artifactId:jar:version:scope
+        e.g. org.apache.commons:commons-compress:jar:1.26.0:compile
+
+    With classifier:
+        [INFO]    groupId:artifactId:jar:classifier:version:scope
+        e.g. io.netty:netty-tcnative-boringssl-static:jar:linux-x86_64:2.0.65.Final:compile
+
+    We detect the classifier by checking if the 4th field looks like a version
+    (starts with a digit). If it does, there's no classifier; if not, it's a classifier
+    and the 5th field is the version.
+    """
+    deps = []
+    # Match lines with [INFO] prefix — 5 or 6 colon-separated fields
+    pattern_info = re.compile(
+        r"^\s*\[INFO\]\s+"
+        r"([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)"
+        r":([a-zA-Z0-9_.-]+)(?::([a-zA-Z0-9_.-]+))?:(\w+)"
+    )
+    # Match lines without [INFO] prefix (e.g. from outputFile)
+    pattern_plain = re.compile(
+        r"^\s*"
+        r"([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)"
+        r":([a-zA-Z0-9_.-]+)(?::([a-zA-Z0-9_.-]+))?:(\w+)"
+    )
+    seen = set()
+
+    for line in output.splitlines():
+        m = pattern_info.match(line) or pattern_plain.match(line)
+        if not m:
+            continue
+
+        group_id = m.group(1)
+        artifact_id = m.group(2)
+        # packaging = m.group(3)  # not used
+        field4 = m.group(4)  # version or classifier
+        field5 = m.group(5)  # version (if classifier present) or None
+        scope = m.group(6)
+
+        # If field5 is present, field4 is the classifier and field5 is the version.
+        # If field5 is None, field4 is the version and there is no classifier.
+        if field5 is not None:
+            classifier = field4
+            version = field5
+        else:
+            # Disambiguate: if field4 starts with a digit, it's a version (no classifier).
+            # If field4 does NOT start with a digit, it's a classifier and version is missing
+            # (shouldn't happen in practice, but handle gracefully).
+            if field4 and field4[0].isdigit():
+                version = field4
+                classifier = ""
+            else:
+                classifier = field4
+                version = ""
+
+        dep = Dependency(
+            group_id=group_id,
+            artifact_id=artifact_id,
+            version=version,
+            classifier=classifier,
+            scope=scope,
+        )
+        if dep not in seen:
+            seen.add(dep)
+            deps.append(dep)
+
+    return deps
+
+
+def resolve_dependencies(project_dir: str) -> List[Dependency]:
+    """Resolve all runtime dependencies for a Maven project."""
+    print("šŸ“¦ Resolving Maven dependencies...")
+    output = _run_maven_dependency_list(project_dir)
+    deps = _parse_dependency_list(output)
+    print(f"   Found {len(deps)} unique runtime dependencies.")
+    return deps
+
+
+def resolve_dependencies_from_pom(project_dir: str) -> List[Dependency]:
+    """
+    Fallback: parse pom.xml for direct dependencies only.
+
+    This does NOT resolve transitive dependencies, so it's less accurate
+    than using `mvn dependency:list`. Used when Maven is not available.
+    Uses regex instead of XML parser for broader compatibility.
+    """
+    pom_path = os.path.join(project_dir, "pom.xml")
+    if not os.path.isfile(pom_path):
+        print(f"āŒ pom.xml not found at {pom_path}", file=sys.stderr)
+        sys.exit(1)
+
+    try:
+        with open(pom_path, "r", encoding="utf-8") as f:
+            content = f.read()
+    except IOError as e:
+        print(f"āŒ Failed to read pom.xml: {e}", file=sys.stderr)
+        sys.exit(1)
+
+    deps = []
+    seen = set()
+
+    # Find all <dependency> blocks
+    dep_pattern = re.compile(
+        r"<dependency>\s*(.*?)\s*</dependency>", re.DOTALL
+    )
+    for match in dep_pattern.finditer(content):
+        block = match.group(1)
+
+        def _extract(tag):
+            m = re.search(rf"<{tag}>\s*([^<]+?)\s*</{tag}>", block)
+            return m.group(1).strip() if m else ""
+
+        group_id = _extract("groupId")
+        artifact_id = _extract("artifactId")
+        version = _extract("version")
+        classifier = _extract("classifier")
+        scope = _extract("scope") or "compile"
+
+        if not group_id or not artifact_id:
+            continue
+
+        dep = Dependency(
+            group_id=group_id,
+            artifact_id=artifact_id,
+            version=version,
+            classifier=classifier,
+            scope=scope,
+        )
+        if dep not in seen:
+            seen.add(dep)
+            deps.append(dep)
+
+    return deps
+
+
+# ---------------------------------------------------------------------------
+# Dependency resolution from an assembled lib/ directory
+# ---------------------------------------------------------------------------
+#
+# For distribution modules (e.g. shenyu-admin-dist) the project cannot always
+# be resolved with `mvn dependency:list` — a pom-packaged aggregator may depend
+# on sibling reactor modules that aren't installed in the local repo. But the
+# assembled distribution already ships a `lib/` directory containing the exact
+# set of runtime jars that will be released, so scanning it is both faster and
+# more accurate than running Maven.
+#
+# Each jar's GAV is read preferentially from META-INF/maven/<gid>/<aid>/pom.properties
+# (exact, zero ambiguity). Jars without pom.properties fall back to filename
+# parsing — pom.properties-first is essential because the filename can be
+# misleading, e.g. `jakarta.mail-2.0.3.jar` is really `jakarta.mail-api:2.1.3`,
+# and `guava-32.0.0-jre.jar` has version `32.0.0-jre` (no classifier). The
+# classifier is never recorded in pom.properties, so it is derived from the
+# filename in both paths.
+
+
+def _parse_pom_properties(text: str) -> Tuple[str, str, str]:
+    """
+    Parse a Java Properties-format pom.properties string.
+
+    Returns (groupId, artifactId, version), empty strings for any missing key.
+    Skips blank lines and full-line `#` comments (Java Properties format).
+    """
+    gid = aid = ver = ""
+    for line in text.splitlines():
+        line = line.strip()
+        if not line or line.startswith("#"):
+            continue
+        if "=" not in line:
+            continue
+        key, _, value = line.partition("=")
+        key = key.strip()
+        value = value.strip()
+        if key == "groupId":
+            gid = value
+        elif key == "artifactId":
+            aid = value
+        elif key == "version":
+            ver = value
+    return gid, aid, ver
+
+
+def _derive_classifier(filename: str, artifact_id: str, version: str) -> str:
+    """
+    Derive the classifier from a jar filename given a known artifactId and version
+    (used in the pom.properties path, where the classifier isn't recorded).
+
+    Filename layout: `<artifactId>-<version>[-<classifier>].jar`
+    Returns "" when there is no classifier or when the version doesn't match the
+    filename (e.g. jakarta.mail-2.0.3.jar with pom.properties version 2.1.3).
+    """
+    stem = filename[:-4] if filename.endswith(".jar") else filename  # strip .jar
+    prefix = artifact_id + "-"
+    if not stem.startswith(prefix):
+        return ""
+    rest = stem[len(prefix):]  # "version" or "version-classifier"
+    if rest == version:
+        return ""
+    if version and rest.startswith(version + "-"):
+        return rest[len(version) + 1:]
+    return ""  # version mismatch — cannot reliably derive classifier
+
+
+def _parse_jar_filename(filename: str) -> Tuple[str, str, str]:
+    """
+    Filename-only fallback for jars without pom.properties.
+
+    Splits on `-`: the first segment starting with a digit is the version,
+    segments before it (joined by `-`) are the artifactId, segments after it
+    (joined by `-`) are the classifier. Returns (artifactId, version, classifier);
+    on failure to locate a version segment, returns ("", stem, "").
+    """
+    stem = filename[:-4] if filename.endswith(".jar") else filename
+    segments = stem.split("-")
+    vidx = None
+    for i, seg in enumerate(segments):
+        if seg and seg[0].isdigit():
+            vidx = i
+            break
+    if vidx is None:
+        return "", stem, ""
+    artifact_id = "-".join(segments[:vidx])
+    version = segments[vidx]
+    classifier = "-".join(segments[vidx + 1:]) if vidx + 1 < len(segments) else ""
+    return artifact_id, version, classifier
+
+
+def _extract_gav_from_jar(jar_path: str) -> Optional[Tuple[str, str, str, str]]:
+    """
+    Extract (groupId, artifactId, version, classifier) from a jar.
+
+    Prefers META-INF/maven/*/pom.properties (exact GAV; classifier derived from
+    the filename). Falls back to filename parsing with groupId="" when no
+    pom.properties is present or the jar cannot be read as a zip.
+    """
+    filename = os.path.basename(jar_path)
+    pom_text = None
+    try:
+        with zipfile.ZipFile(jar_path) as zf:
+            for name in zf.namelist():
+                if name.startswith("META-INF/maven/") and name.endswith("/pom.properties"):
+                    pom_text = zf.read(name).decode("utf-8", "replace")
+                    break
+    except (zipfile.BadZipFile, OSError):
+        pom_text = None
+
+    if pom_text:
+        gid, aid, ver = _parse_pom_properties(pom_text)
+        if aid and ver:
+            classifier = _derive_classifier(filename, aid, ver)
+            return gid, aid, ver, classifier
+
+    # Fallback: parse the filename. groupId is unknown.
+    aid, ver, classifier = _parse_jar_filename(filename)
+    return "", aid, ver, classifier
+
+
+def resolve_dependencies_from_lib(lib_dir: str) -> List[Dependency]:
+    """
+    Resolve dependencies by scanning the jars in an assembled lib/ directory.
+
+    Each jar's GAV is read from its embedded pom.properties when available,
+    otherwise parsed from the filename (groupId unknown in that case).
+    """
+    lib_dir = os.path.abspath(lib_dir)
+    if not os.path.isdir(lib_dir):
+        print(f"āŒ lib directory not found: {lib_dir}", file=sys.stderr)
+        sys.exit(1)
+
+    jars = sorted(glob.glob(os.path.join(lib_dir, "*.jar")))
+    if not jars:
+        print(f"āš ļø  No .jar files found in {lib_dir}", file=sys.stderr)
+
+    print("šŸ“¦ Scanning JARs in lib directory...")
+    deps: List[Dependency] = []
+    seen = set()
+    n_props = 0
+    n_filename = 0
+
+    for jar_path in jars:
+        gav = _extract_gav_from_jar(jar_path)
+        if gav is None:
+            continue
+        gid, aid, ver, classifier = gav
+        if not aid or not ver:
+            # Could not parse at all — skip rather than emit garbage.
+            continue
+
+        if gid:
+            n_props += 1
+        else:
+            n_filename += 1
+
+        dep = Dependency(
+            group_id=gid,
+            artifact_id=aid,
+            version=ver,
+            classifier=classifier,
+            scope="compile",  # lib/ ships only runtime jars
+        )
+        if dep not in seen:
+            seen.add(dep)
+            deps.append(dep)
+
+    print(f"   Found {len(deps)} unique dependencies "
+          f"({n_props} from pom.properties, {n_filename} from filename).")
+    if n_filename:
+        print(f"   ({n_filename} jars had no pom.properties — resolved from filename, "
+              f"groupId unknown; license auto-fix will skip these.)")
+    return deps
+
+
+def resolve_lib_dir(project_dir: str, explicit_lib: Optional[str]) -> Optional[str]:
+    """
+    Determine the lib/ directory to scan.
+
+    If an explicit path is given, validate and use it. Otherwise auto-detect
+    under <project_dir>/target/*-bin/lib then target/*/lib (version-agnostic,
+    matching distribution output dirs like apache-shenyu-2.7.1-admin-bin/lib).
+    Returns None when no lib dir is found.
+    """
+    if explicit_lib:
+        p = os.path.abspath(explicit_lib)
+        if not os.path.isdir(p):
+            print(f"āŒ --lib path is not a directory: {p}", file=sys.stderr)
+            sys.exit(1)
+        return p
+
+    for pattern in ("*-bin", "*"):
+        for cand in glob.glob(os.path.join(project_dir, "target", pattern, "lib")):
+            if os.path.isdir(cand):
+                return cand
+    return None
+
+
+# ---------------------------------------------------------------------------
+# License file handling
+# ---------------------------------------------------------------------------
+
+def _find_license_file(project_dir: str) -> Optional[str]:
+    """
+    Auto-locate the LICENSE file in common locations.
+
+    Search order:
+      1. <project_dir>/LICENSE
+      2. <project_dir>/src/main/release-docs/LICENSE   (ShenYu dist layout)
+      3. <project_dir>/target/maven-shared-archive-resources/META-INF/LICENSE
+      4. Any LICENSE file found under src/
+    """
+    candidates = [
+        os.path.join(project_dir, "LICENSE"),
+        os.path.join(project_dir, "src", "main", "release-docs", "LICENSE"),
+        os.path.join(project_dir, "target", "maven-shared-archive-resources",
+                      "META-INF", "LICENSE"),
+    ]
+    for path in candidates:
+        if os.path.isfile(path):
+            return path
+
+    # Last resort: find any LICENSE under src/
+    for root, dirs, files in os.walk(os.path.join(project_dir, "src")):
+        if "LICENSE" in files:
+            return os.path.join(root, "LICENSE")
+
+    return None
+
+
+def read_license(license_path: str) -> str:
+    """Read the LICENSE file content."""
+    if not os.path.isfile(license_path):
+        print(f"āŒ LICENSE file not found at {license_path}", file=sys.stderr)
+        sys.exit(1)
+
+    with open(license_path, "r", encoding="utf-8", errors="replace") as f:
+        return f.read()
+
+
+def extract_declaration_section(license_content: str) -> str:
+    """
+    Extract only the dependency declaration section from a LICENSE file.
+
+    Apache-style LICENSE files have a standard structure:
+      - Top: Apache License 2.0 full text (boilerplate)
+      - Bottom: "Subcomponents" / "Third-party" declarations
+
+    The boilerplate contains common English words like "annotations",
+    "distribution", "license" etc. that can cause false matches.
+
+    We find the declaration section by looking for common section markers.
+    If none found, we return the full content (for non-Apache projects).
+    """
+    # Common markers that indicate the start of dependency declarations
+    markers = [
+        "=======================================================================\n",
+        "Subcomponents:",
+        "Third-party",
+        "third-party",
+        "3rd party",
+        "The following",
+        "This product includes",
+        "This product bundles",
+    ]
+
+    best_pos = len(license_content)
+
+    for marker in markers:
+        pos = license_content.find(marker)
+        if pos != -1 and pos < best_pos:
+            best_pos = pos
+
+    # If we found a marker, return from that point onward
+    if best_pos < len(license_content):
+        return license_content[best_pos:]
+
+    # No marker found — return the full content
+    return license_content
+
+
+# ---------------------------------------------------------------------------
+# License matching
+# ---------------------------------------------------------------------------
+
+def check_dependency_in_license(dep: Dependency, license_content: str) -> bool:
+    """
+    Check whether a dependency is declared in the LICENSE file.
+
+    Uses the declaration section only (skipping license boilerplate) to
+    avoid false positives from common English words in the license text.
+
+    Matching strategies (in order of precision):
+      1. "groupId:artifactId:version"           (Maven coordinate — most precise)
+      2. "groupId artifactId version"           (space-separated coordinate)
+      3. "artifactId-version-classifier.jar"    (jar filename with classifier)
+      4. "artifactId version-classifier"        (version+classifier together)
+      5. "artifactId-version.jar"               (jar filename without classifier)
+      6. "artifactId version"                   (what the Java tool used)
+    """
+    # Only search in the declaration section to avoid false positives
+    search_area = extract_declaration_section(license_content)
+    normalized_area = " ".join(search_area.split())
+
+    # Strategy 1: Maven coordinate (groupId:artifactId:version)
+    coord = f"{dep.group_id}:{dep.artifact_id}:{dep.version}"
+    if _literal_in(coord, normalized_area):
+        return True
+
+    # Strategy 2: groupId + artifactId + version (space-separated)
+    gav = f"{dep.group_id} {dep.artifact_id} {dep.version}"
+    if _literal_in(gav, normalized_area):
+        return True
+
+    # Strategy 3: jar filename with classifier (artifactId-version-classifier.jar)
+    if dep.classifier:
+        if _literal_in(dep.jar_name, normalized_area):
+            return True
+
+        # Strategy 4: artifactId + version-classifier (e.g. "shiro-core 1.13.0-jakarta")
+        av_classifier = f"{dep.artifact_id} {dep.version}-{dep.classifier}"
+        if _literal_in(av_classifier, normalized_area):
+            return True
+
+        # Strategy 4b: artifactId + classifier + version (alternate format)
+        av_alt = f"{dep.artifact_id} {dep.classifier} {dep.version}"
+        if _literal_in(av_alt, normalized_area):
+            return True
+
+    # Strategy 5: jar filename without classifier (artifactId-version.jar)
+    jar_no_classifier = f"{dep.artifact_id}-{dep.version}.jar"
+    if _literal_in(jar_no_classifier, normalized_area):
+        return True
+
+    # Strategy 6: artifactId + version (original Java tool's approach)
+    av = f"{dep.artifact_id} {dep.version}"
+    if _literal_in(av, normalized_area):
+        return True
+
+    return False
+
+
+def _literal_in(target: str, normalized_text: str) -> bool:
+    """Check if target appears in pre-normalized text as a literal string."""
+    normalized_target = " ".join(target.split())
+    return normalized_target in normalized_text
+
+
+# ---------------------------------------------------------------------------
+# POM license resolution via Maven Central
+# ---------------------------------------------------------------------------
+
+MAVEN_CENTRAL_BASE = "https://repo1.maven.org/maven2"
+
+# In-memory cache: (groupId, artifactId, version) -> license_info dict
+_pom_cache: Dict[Tuple[str, str, str], dict] = {}
+
+
+def _group_id_to_path(group_id: str) -> str:
+    """Convert groupId to Maven repo path: org.apache.commons -> org/apache/commons"""
+    return group_id.replace(".", "/")
+
+
+def _fetch_url(url: str, timeout: int = 10) -> Optional[str]:
+    """Fetch a URL and return its text content, or None on failure."""
+    try:
+        req = urllib.request.Request(url, headers={"User-Agent": "shenyu-watcher/1.0"})
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            return resp.read().decode("utf-8", errors="replace")
+    except (urllib.error.HTTPError, urllib.error.URLError, OSError):
+        return None
+
+
+# In-memory cache: artifactId -> resolved groupId (for jars without pom.properties)
+_group_id_cache: Dict[Tuple[str, str], Optional[str]] = {}
+
+
+def _pom_exists(group_id: str, artifact_id: str, version: str) -> bool:
+    """Check whether a POM exists on Maven Central for the given coordinates."""
+    gpath = _group_id_to_path(group_id)
+    url = (f"{MAVEN_CENTRAL_BASE}/{gpath}/{artifact_id}/{version}/"
+           f"{artifact_id}-{version}.pom")
+    try:
+        req = urllib.request.Request(url, headers={"User-Agent": "shenyu-watcher/1.0"},
+                                     method="HEAD")
+        with urllib.request.urlopen(req, timeout=10) as resp:
+            return resp.status == 200
+    except (urllib.error.HTTPError, urllib.error.URLError, OSError):
+        return False
+
+
+def _resolve_group_id_from_central(artifact_id: str, version: str) -> Optional[str]:
+    """
+    Reverse-lookup a dependency's groupId on Maven Central when only the
+    artifactId + version are known (jar had no pom.properties).
+
+    Two-tier strategy:
+      1. Search by `a:<artifactId> AND v:<version>` — precise when indexed.
+      2. If that misses (Maven Central's search index lags behind newly
+         published versions), list candidates by `a:<artifactId>` and verify
+         each candidate groupId by checking the POM exists at that version.
+    Returns the resolved groupId, or None if it can't be determined.
+    """
+    cache_key = (artifact_id, version)
+    if cache_key in _group_id_cache:
+        return _group_id_cache[cache_key]
+
+    import json as _json
+
+    def _search(query: str) -> List[str]:
+        url = (f"https://search.maven.org/solrsearch/select?q={query}"
+               f"&rows=20&wt=json")
+        text = _fetch_url(url)
+        if not text:
+            return []
+        try:
+            docs = _json.loads(text).get("response", {}).get("docs", [])
+            return [d.get("g") for d in docs if d.get("g")]
+        except (ValueError, AttributeError):
+            return []
+
+    # Tier 1: exact artifactId + version
+    candidates = _search(f"a:{artifact_id}+AND+v:{version}")
+    if candidates:
+        _group_id_cache[cache_key] = candidates[0]
+        return candidates[0]
+
+    # Tier 2: list by artifactId, verify each candidate's POM exists at this version
+    for gid in _search(f"a:{artifact_id}"):
+        if gid and _pom_exists(gid, artifact_id, version):
+            _group_id_cache[cache_key] = gid
+            return gid
+
+    _group_id_cache[cache_key] = None
+    return None
+
+
+def _parse_pom_licenses(pom_text: str) -> List[dict]:
+    """Extract <licenses> section from POM XML text using regex."""
+    licenses = []
+    # Find all <license> blocks
+    for m in re.finditer(r"<license>\s*(.*?)\s*</license>", pom_text, re.DOTALL):
+        block = m.group(1)
+        name = ""
+        url = ""
+        for tag in ("name", "url"):
+            tm = re.search(rf"<{tag}>\s*(.*?)\s*</{tag}>", block, re.DOTALL)
+            if tm:
+                val = tm.group(1).strip()
+                if tag == "name":
+                    name = val
+                else:
+                    url = val
+        if name:
+            licenses.append({"name": name, "url": url})
+    return licenses
+
+
+def _parse_pom_url(pom_text: str) -> str:
+    """Extract <url> from POM (project URL, not license URL)."""
+    m = re.search(r"<url>\s*(.*?)\s*</url>", pom_text)
+    if m:
+        return m.group(1).strip()
+    return ""
+
+
+def _parse_pom_scm_url(pom_text: str) -> str:
+    """Extract <scm><url> from POM."""
+    m = re.search(r"<scm>.*?<url>\s*(.*?)\s*</url>.*?</scm>", pom_text, re.DOTALL)
+    if m:
+        return m.group(1).strip()
+    return ""
+
+
+def _parse_pom_parent(pom_text: str) -> Optional[Tuple[str, str, str]]:
+    """Extract <parent><groupId><artifactId><version> from POM."""
+    parent_match = re.search(r"<parent>\s*(.*?)\s*</parent>", pom_text, re.DOTALL)
+    if not parent_match:
+        return None
+    block = parent_match.group(1)
+    gid = aid = ver = ""
+    for tag, attr in [("groupId", "gid"), ("artifactId", "aid"), ("version", "ver")]:
+        tm = re.search(rf"<{tag}>\s*(.*?)\s*</{tag}>", block)
+        if tm:
+            val = tm.group(1).strip()
+            if attr == "gid":
+                gid = val
+            elif attr == "aid":
+                aid = val
+            else:
+                ver = val
+    if gid and aid and ver:
+        return (gid, aid, ver)
+    return None
+
+
+def _is_likely_fork_url(url: str, group_id: str, artifact_id: str) -> bool:
+    """
+    Heuristic: detect if a GitHub URL likely points to a fork rather than the
+    canonical upstream project.
+
+    A URL is considered a likely fork when the GitHub owner/organization
+    doesn't correspond to the project name or groupId. For example:
+      - at.yawk.lz4:lz4-java → https://github.com/yawkat/lz4-java (fork)
+      - org.lz4:lz4-java → https://github.com/lz4/lz4-java (canonical)
+
+    Returns True if the URL looks like a fork.
+    """
+    m = re.match(r"https?://github\.com/([^/]+)/([^/\s?#]+)", url)
+    if not m:
+        return False
+    owner = m.group(1).lower()
+    repo = m.group(2).lower()
+    gid_lower = group_id.lower()
+    aid_lower = artifact_id.lower()
+
+    # The owner should relate to the groupId or artifactId.
+    # Common patterns for canonical projects:
+    #   groupId "org.lz4" → owner "lz4"
+    #   groupId "com.google.guava" → owner "google"
+    #   groupId "io.netty" → owner "netty"
+    # A fork typically has a personal username that doesn't match.
+
+    # Extract the last segment of groupId (e.g., "lz4" from "at.yawk.lz4")
+    gid_last = gid_lower.rsplit(".", 1)[-1]
+    # Also extract second-to-last if exists (e.g., "yawk" from "at.yawk.lz4")
+    gid_parts = gid_lower.split(".")
+
+    # If owner matches any part of groupId or artifactId, it's likely canonical
+    if owner in gid_parts or owner == gid_last or owner == aid_lower:
+        return False
+
+    # If owner matches the repo name (common for orgs like netty/netty, lz4/lz4-java)
+    if owner == repo.split("-")[0].replace(".", ""):
+        return False
+
+    # If owner is a known org domain segment (google, apache, spring-projects, etc.)
+    known_orgs = {
+        "google", "apache", "spring-projects", "eclipse", "netty",
+        "alibaba", "facebook", "twitter", "linkedin", "netflix",
+        "oracle", "microsoft", "aws", "cloudflare",
+    }
+    if owner in known_orgs:
+        return False
+
+    # Otherwise, it's likely a personal fork
+    return True
+
+
+def fetch_pom_license(dep: Dependency, max_parent_depth: int = 3) -> dict:
+    """
+    Fetch license info for a dependency from Maven Central POM.
+
+    Resolves parent POMs recursively (up to max_parent_depth levels).
+    Returns dict with keys: license_name, license_url, project_url
+    """
+    cache_key = (dep.group_id, dep.artifact_id, dep.version)
+    if cache_key in _pom_cache:
+        return _pom_cache[cache_key]
+
+    # Without a groupId we cannot build a Maven Central URL (the jar had no
+    # pom.properties and was resolved from its filename). Return a sentinel so
+    # the caller can skip auto-append rather than silently defaulting to Apache 2.0.
+    if not dep.group_id:
+        result = {
+            "license_name": "Unknown (no groupId — manual lookup required)",
+            "license_url": "",
+            "project_url": f"https://mvnrepository.com/artifact/{dep.artifact_id}",
+        }
+        _pom_cache[cache_key] = result
+        return result
+
+    result = {
+        "license_name": "Apache 2.0",  # sensible default
+        "license_url": "",
+        "project_url": f"https://mvnrepository.com/artifact/{dep.group_id}/{dep.artifact_id}",
+    }
+
+    pom_text = None
+    parent_info = None
+
+    # Try to fetch the artifact's own POM
+    gpath = _group_id_to_path(dep.group_id)
+    pom_url = f"{MAVEN_CENTRAL_BASE}/{gpath}/{dep.artifact_id}/{dep.version}/{dep.artifact_id}-{dep.version}.pom"
+    pom_text = _fetch_url(pom_url)
+
+    if pom_text:
+        # Extract project URL and SCM URL
+        # Prefer <url> (project homepage) over <scm><url> (may point to a fork)
+        project_url = _parse_pom_url(pom_text)
+        scm_url = _parse_pom_scm_url(pom_text)
+        if scm_url:
+            # Clean up SCM URLs (git scm:git:https://... -> https://...)
+            scm_url = re.sub(r"^scm:git:", "", scm_url)
+            if scm_url.endswith(".git"):
+                scm_url = scm_url[:-4]
+
+        # Pick the best URL: <url> preferred, <scm><url> as fallback
+        chosen_url = project_url or scm_url or ""
+
+        # Heuristic: if the GitHub URL owner doesn't look like the canonical project,
+        # fall back to mvnrepository which always shows the correct upstream link.
+        # E.g. at.yawk.lz4:lz4-java has <url>https://github.com/yawkat/lz4-java</url>
+        # but the upstream is https://github.com/lz4/lz4-java
+        mvn_url = f"https://mvnrepository.com/artifact/{dep.group_id}/{dep.artifact_id}"
+        if chosen_url and _is_likely_fork_url(chosen_url, dep.group_id, dep.artifact_id):
+            chosen_url = mvn_url
+
+        if chosen_url:
+            result["project_url"] = chosen_url
+
+        # Extract licenses
+        licenses = _parse_pom_licenses(pom_text)
+        if licenses:
+            result["license_name"] = licenses[0]["name"]
+            result["license_url"] = licenses[0].get("url", "")
+            _pom_cache[cache_key] = result
+            return result
+
+        # No licenses in this POM — try parent
+        parent_info = _parse_pom_parent(pom_text)
+
+    # Resolve parent POMs
+    depth = 0
+    while parent_info and depth < max_parent_depth:
+        p_gid, p_aid, p_ver = parent_info
+        p_cache_key = (p_gid, p_aid, p_ver)
+        if p_cache_key in _pom_cache:
+            parent_result = _pom_cache[p_cache_key]
+            result["license_name"] = parent_result["license_name"]
+            result["license_url"] = parent_result["license_url"]
+            if not result["project_url"].startswith("https://mvnrepository.com"):
+                pass  # keep existing project_url
+            else:
+                result["project_url"] = parent_result.get("project_url", result["project_url"])
+            break
+
+        p_gpath = _group_id_to_path(p_gid)
+        p_pom_url = f"{MAVEN_CENTRAL_BASE}/{p_gpath}/{p_aid}/{p_ver}/{p_aid}-{p_ver}.pom"
+        p_pom_text = _fetch_url(p_pom_url)
+
+        if not p_pom_text:
+            break
+
+        # Extract licenses from parent
+        p_licenses = _parse_pom_licenses(p_pom_text)
+        if p_licenses:
+            result["license_name"] = p_licenses[0]["name"]
+            result["license_url"] = p_licenses[0].get("url", "")
+            # Also try to get project URL from parent
+            if result["project_url"].startswith("https://mvnrepository.com"):
+                p_project_url = _parse_pom_url(p_pom_text)
+                p_scm_url = _parse_pom_scm_url(p_pom_text)
+                if p_scm_url:
+                    p_scm_url = re.sub(r"^scm:git:", "", p_scm_url)
+                    if p_scm_url.endswith(".git"):
+                        p_scm_url = p_scm_url[:-4]
+                # Prefer project <url> over <scm><url>
+                if p_project_url:
+                    result["project_url"] = p_project_url
+                elif p_scm_url:
+                    result["project_url"] = p_scm_url
+            _pom_cache[p_cache_key] = {
+                "license_name": p_licenses[0]["name"],
+                "license_url": p_licenses[0].get("url", ""),
+                "project_url": result["project_url"],
+            }
+            break
+
+        # Try next parent level
+        parent_info = _parse_pom_parent(p_pom_text)
+        depth += 1
+
+    _pom_cache[cache_key] = result
+    return result
+
+
+# ---------------------------------------------------------------------------
+# License section mapping
+# ---------------------------------------------------------------------------
+
+# Mapping: keyword in POM license name -> LICENSE section header
+# Order matters: more specific patterns first, then general ones as fallback.
+LICENSE_SECTION_MAP = [
+    ("bouncy castle", "Bouncy Castle licenses"),
+    ("cddl", "CDDL licenses"),
+    ("cc0", "CC0 licenses"),
+    ("creative commons cc0", "CC0 licenses"),
+    ("eclipse distribution license", "EDL License"),
+    ("edl", "EDL License"),
+    ("epl", "EPL licenses"),
+    ("eclipse public license", "EPL licenses"),
+    ("isc", "ISC licenses"),
+    ("mpl", "MPL licenses"),
+    ("mozilla public license", "MPL licenses"),
+    ("public domain", "Public Domain licenses"),
+    ("wtfpl", "WTFPL License"),
+    ("xpp", "XPP License"),
+    ("xml pull parser", "XPP License"),
+    # "Go License" is used by re2j and other Google projects — it is BSD 3-Clause
+    ("go license", "BSD licenses"),
+    ("lgpl", "LGPL licenses"),
+    ("lesser general public license", "LGPL licenses"),
+    ("gpl", "GPL licenses"),
+    ("general public license", "GPL licenses"),
+    ("bsd", "BSD licenses"),
+    ("mit", "MIT licenses"),
+    ("apache", "Apache 2.0 licenses"),
+]
+
+
+def map_license_to_section(license_name: str) -> str:
+    """Map a POM license name to the LICENSE section header."""
+    lower = license_name.lower()
+    for keyword, section in LICENSE_SECTION_MAP:
+        if keyword in lower:
+            return section
+    # Unknown license — warn and default to Apache 2.0 (most common in Java ecosystem)
+    print(f"  {YELLOW}āš ļø  Unknown license '{license_name}', defaulting to Apache 2.0 — please verify manually{RESET}")
+    return "Apache 2.0 licenses"
+
+
+# ---------------------------------------------------------------------------
+# LICENSE file fix logic
+# ---------------------------------------------------------------------------
+
+def format_license_entry(dep: Dependency, license_info: dict) -> str:
+    """
+    Format a single LICENSE entry line.
+
+    Format: "    <artifactId> <version>[-<classifier>]: <url>, <LicenseName>"
+    """
+    version_display = f"{dep.version}-{dep.classifier}" if dep.classifier else dep.version
+    project_url = license_info.get("project_url", "")
+    license_name = license_info.get("license_name", "Apache 2.0")
+
+    # Clean up license name for display
+    # Common POM names -> shorter LICENSE-friendly forms
+    name_cleanups = {
+        "Apache License, Version 2.0": "Apache 2.0",
+        "The Apache Software License, Version 2.0": "Apache 2.0",
+        "Apache License 2.0": "Apache 2.0",
+        "Apache License Version 2.0": "Apache 2.0",
+        "Apache-2.0": "Apache 2.0",
+        "The MIT License": "MIT",
+        "MIT License": "MIT",
+        "SPDX-License-Identifier: MIT": "MIT",
+        "BSD-2-Clause": "BSD 2-Clause",
+        "BSD-3-Clause": "BSD 3-Clause",
+        "The BSD License": "BSD",
+        "Go License": "BSD 3-Clause",
+        "Eclipse Public License - v 2.0": "EPL 2.0",
+        "Eclipse Public License v1.0": "EPL 1.0",
+        "Eclipse Distribution License - v 1.0": "EDL 1.0",
+        "Mozilla Public License, Version 2.0": "MPL 2.0",
+        "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0": "CDDL 1.0",
+        "Bouncy Castle Licence": "Bouncy Castle",
+        "Public Domain, per Creative Commons CC0": "CC0 1.0",
+    }
+
+    display_name = name_cleanups.get(license_name, license_name)
+
+    if project_url:
+        return f"    {dep.artifact_id} {version_display}: {project_url}, {display_name}"
+    else:
+        mvn_url = f"https://mvnrepository.com/artifact/{dep.group_id}/{dep.artifact_id}"
+        return f"    {dep.artifact_id} {version_display}: {mvn_url}, {display_name}"
+
+
+def find_section_range(license_content: str, section_header: str) -> Optional[Tuple[int, int]]:
+    """
+    Find the start and end line indices of a section in the LICENSE file.
+
+    A section is delimited by lines of '=' characters with the header text between.
+    Returns (section_start_line, section_end_line) or None if not found.
+    """
+    lines = license_content.split("\n")
+    section_start = None
+    separator_pattern = re.compile(r"^={10,}$")
+
+    # Find the section header line
+    for i, line in enumerate(lines):
+        stripped = line.strip()
+        if stripped == section_header:
+            # The header line is between two separator lines
+            # section_start is the line of the first '=' separator
+            section_start = i - 1  # the '=' line above the header
+            break
+
+    if section_start is None or section_start < 0:
+        return None
+
+    # Find the end: the next separator line after the header's closing separator
+    # The header line is at section_start + 1, closing separator at section_start + 2
+    # Content starts at section_start + 3 (after blank line)
+    # End is the next line starting with '=' or EOF
+    section_end = len(lines)  # default to EOF
+    for i in range(section_start + 3, len(lines)):
+        if separator_pattern.match(lines[i].strip()):
+            section_end = i
+            break
+
+    return (section_start, section_end)
+
+
+def fix_license_file(license_path: str, result: CheckResult) -> List[FixEntry]:
+    """
+    Automatically append missing dependency entries to the LICENSE file.
+
+    For each unmatched dependency:
+    1. Fetches license info from Maven Central POM
+    2. Maps the license to the correct LICENSE section
+    3. Formats and appends the entry
+
+    Returns the list of entries actually written (excluding duplicates that
+    were already present). Callers (e.g. the --pr flow) use this to build the
+    commit message and PR body.
+    """
+    if not result.unmatched:
+        print(f"\n{GREEN}No missing entries to fix.{RESET}")
+        return []
+
+    # Read current LICENSE
+    license_content = read_license(license_path)
+
+    # Group unmatched deps by their target section
+    from collections import defaultdict
+    section_deps = defaultdict(list)  # section_header -> [(dep, entry_line)]
+
+    print(f"šŸ” Resolving license info for {len(result.unmatched)} missing dependencies...")
+
+    for dep in sorted(result.unmatched, key=lambda d: (d.group_id, d.artifact_id)):
+        if not dep.group_id:
+            # The jar had no pom.properties — try to recover the groupId from
+            # Maven Central by artifactId + version so we can still auto-fix it.
+            resolved = _resolve_group_id_from_central(dep.artifact_id, dep.version)
+            if resolved:
+                print(f"   {dep.artifact_id} {dep.version}: groupId resolved from Maven Central "
+                      f"-> {resolved}")
+                dep.group_id = resolved
+            else:
+                print(f"  {YELLOW}āš ļø  Skipping auto-fix for {dep.artifact_id} {dep.version}: "
+                      f"groupId unknown (no pom.properties in jar, and not found on Maven Central). "
+                      f"Add manually.{RESET}")
+                continue
+        license_info = fetch_pom_license(dep)
+        section = map_license_to_section(license_info["license_name"])
+        entry_line = format_license_entry(dep, license_info)
+        section_deps[section].append((dep, entry_line, license_info))
+        license_display = license_info.get("license_name", "unknown")
+        print(f"   {dep.artifact_id} {dep.version} → {section} ({license_display})")
+
+    if not section_deps:
+        print(f"\n{YELLOW}No entries could be auto-fixed (all missing deps lack a groupId).{RESET}")
+        return []
+
+    # Now modify the LICENSE file
+    lines = license_content.split("\n")
+    total_added = 0
+    written: List[FixEntry] = []
+
+    # Process sections — we need to insert from bottom to top to preserve line numbers
+    # First, collect all insertion points
+    insertions = []  # (line_index, entries_to_add)
+
+    for section_header, dep_entries in sorted(section_deps.items()):
+        section_range = find_section_range(license_content, section_header)
+
+        if section_range is None:
+            # Section doesn't exist yet — we'll need to create it
+            # Append at the end of the file
+            insert_point = len(lines)
+            insertions.append((insert_point, section_header, dep_entries, True))
+        else:
+            # Find the last non-empty line within the section
+            start, end = section_range
+            # Find last content line (skip trailing blank lines)
+            last_content = end - 1
+            while last_content > start and lines[last_content].strip() == "":
+                last_content -= 1
+            insertions.append((last_content + 1, section_header, dep_entries, False))
+
+    # Sort insertions by line index descending (insert from bottom to top)
+    insertions.sort(key=lambda x: x[0], reverse=True)
+
+    for insert_idx, section_header, dep_entries, is_new_section in insertions:
+        new_lines = []
+        if is_new_section:
+            # Create a new section at the end
+            # Derive a clean license name from the section header by stripping a
+            # trailing " licenses"/" License" suffix (e.g. "LGPL licenses" -> "LGPL").
+            # Use suffix removal, NOT str.rstrip (which strips a character set and
+            # would mangle "LGPL licenses" into "LGPL l").
+            lic_name = section_header
+            for suffix in (" licenses", " License", " licenses:"):
+                if lic_name.endswith(suffix):
+                    lic_name = lic_name[:-len(suffix)]
+                    break
+            new_lines.append("")  # blank line before section
+            new_lines.append("=" * 72)
+            new_lines.append(section_header)
+            new_lines.append("=" * 72)
+            new_lines.append("")
+            new_lines.append(f"The following components are provided under a {lic_name} license. "
+                           f"See project link for details.")
+            new_lines.append("")
+
+        for dep, entry_line, license_info in dep_entries:
+            # Check if this exact entry already exists (avoid duplicates)
+            if entry_line.strip() not in license_content:
+                new_lines.append(entry_line)
+                total_added += 1
+                written.append(FixEntry(
+                    section=section_header,
+                    dep=dep,
+                    entry_line=entry_line,
+                    license_name=license_info.get("license_name", ""),
+                ))
+
+        if is_new_section and new_lines:
+            # Ensure we add at the end
+            lines.extend(new_lines)
+        elif new_lines:
+            # Insert at the correct position
+            for i, line in enumerate(new_lines):
+                lines.insert(insert_idx + i, line)
+
+    # Write updated LICENSE
+    updated_content = "\n".join(lines)
+    # Clean up trailing whitespace but preserve final newline
+    updated_content = updated_content.rstrip("\n") + "\n"
+
+    with open(license_path, "w", encoding="utf-8") as f:
+        f.write(updated_content)
+
+    print(f"\n{GREEN}{BOLD}āœ… Added {total_added} entr{'y' if total_added == 1 else 'ies'} to LICENSE{RESET}")
+    print(f"   File updated: {license_path}")
+
+    # Show a summary of what was added
+    print(f"\n{BOLD}Added entries by section:{RESET}")
+    for section_header, dep_entries in sorted(section_deps.items()):
+        print(f"  {section_header}:")
+        for dep, entry_line, license_info in dep_entries:
+            print(f"    + {entry_line.strip()}")
+
+    return written
+
+
+# ---------------------------------------------------------------------------
+# Main check logic
+# ---------------------------------------------------------------------------
+
+
+def run_check(project_dir: str, license_path: Optional[str] = None,
+              lib_dir: Optional[str] = None, no_lib: bool = False) -> CheckResult:
+    """
+    Run the full license check.
+
+    Args:
+        project_dir: Path to the Maven project root (containing pom.xml).
+        license_path: Path to the LICENSE file. Defaults to <project_dir>/LICENSE.
+        lib_dir: Explicit path to an assembled lib/ directory to scan instead of
+            running Maven. When None and no_lib is False, auto-detected under
+            <project_dir>/target/*-bin/lib.
+        no_lib: If True, disable lib-directory scanning and force Maven.
+
+    Returns:
+        CheckResult with matched/unmatched/skipped details.
+    """
+    project_dir = os.path.abspath(project_dir)
+
+    # 1. Resolve dependencies — prefer an assembled lib/ dir (fast, accurate,
+    #    and works for distribution modules Maven can't resolve), then Maven,
+    #    then pom.xml parsing as a last-resort fallback.
+    resolved_lib = None if no_lib else resolve_lib_dir(project_dir, lib_dir)
+    if resolved_lib:
+        deps = resolve_dependencies_from_lib(resolved_lib)
+    else:
+        try:
+            deps = resolve_dependencies(project_dir)
+        except SystemExit:
+            print("āš ļø  Maven resolution failed, falling back to pom.xml parsing (direct deps only).")
+            deps = resolve_dependencies_from_pom(project_dir)
+        except Exception:
+            print("āš ļø  Maven resolution failed, falling back to pom.xml parsing (direct deps only).")
+            deps = resolve_dependencies_from_pom(project_dir)
+
+    if not deps:
+        print("āš ļø  No dependencies found.", file=sys.stderr)
+        return CheckResult()
+
+    # 2. Locate and read LICENSE
+    if license_path is not None:
+        license_path = os.path.abspath(license_path)
+    else:
+        license_path = _find_license_file(project_dir)
+        if license_path is None:
+            print(f"āŒ LICENSE file not found in {project_dir}", file=sys.stderr)
+            print(f"   Searched: LICENSE, src/main/release-docs/LICENSE, target/.../META-INF/LICENSE",
+                  file=sys.stderr)
+            print(f"   Hint: specify the LICENSE path explicitly as the 2nd argument.", file=sys.stderr)
+            sys.exit(1)
+    print(f"šŸ“„ Using LICENSE: {license_path}")
+    license_content = read_license(license_path)
+
+    # 3. Skip project's own modules by artifactId keyword (e.g. "shenyu")
+    #    This avoids false positives from broad groupIds like "org.apache"
+    skip_keywords = {"shenyu"}
+
+    # 4. Check each dependency
+    result = CheckResult(total=len(deps))
+
+    for dep in deps:
+        # Skip project's own modules (by artifactId keyword, e.g. "shenyu")
+        if any(kw in dep.artifact_id for kw in skip_keywords):
+            result.skipped_project += 1
+            continue
+
+        # Skip test/provided/system scopes (not shipped in lib/)
+        if dep.scope in ("test", "provided", "system"):
+            result.skipped_scope.append(dep)
+            continue
+
+        result.checked += 1
+
+        if check_dependency_in_license(dep, license_content):
+            result.matched.append(dep)
+        else:
+            result.unmatched.append(dep)
+
+    return result
+
+
+# ---------------------------------------------------------------------------
+# Output formatting
+# ---------------------------------------------------------------------------
+
+# ANSI colors
+RED = "\033[91m"
+GREEN = "\033[92m"
+YELLOW = "\033[93m"
+CYAN = "\033[96m"
+BOLD = "\033[1m"
+RESET = "\033[0m"
+
+
+def print_result(result: CheckResult):
+    """Print a human-readable check result."""
+    print()
+    print(f"{BOLD}{'=' * 60}{RESET}")
+    print(f"{BOLD}  License Check Report{RESET}")
+    print(f"{BOLD}{'=' * 60}{RESET}")
+    print()
+    print(f"  Total dependencies:      {result.total}")
+    print(f"  Checked:                 {result.checked}")
+    print(f"  Skipped (project):       {result.skipped_project}")
+    print(f"  Skipped (test/provided): {len(result.skipped_scope)}")
+    print(f"  {GREEN}Matched in LICENSE:       {len(result.matched)}{RESET}")
+    print(f"  {RED}NOT matched in LICENSE:    {len(result.unmatched)}{RESET}")
+    print()
+
+    if result.unmatched:
+        # Group unmatched by groupId for a concise summary
+        from collections import defaultdict
+        by_group = defaultdict(list)
+        for dep in result.unmatched:
+            by_group[dep.group_id].append(dep)
+
+        print(f"{RED}{BOLD}āŒ {len(result.unmatched)} dependency(ies) NOT declared in LICENSE{RESET}")
+        print(f"{RED}   (grouped by groupId, {len(by_group)} groups){RESET}")
+        print(f"{RED}{'─' * 60}{RESET}")
+        for gid in sorted(by_group):
+            deps_in_group = sorted(by_group[gid], key=lambda d: d.artifact_id)
+            print(f"  {BOLD}{gid}{RESET} ({len(deps_in_group)} artifact(s))")
+            for dep in deps_in_group:
+                version_display = f"{dep.version}-{dep.classifier}" if dep.classifier else dep.version
+                print(f"    {RED}• {dep.artifact_id}:{version_display}{RESET}")
+        print()
+
+        # Provide actionable suggestions — grouped by groupId
+        print(f"{YELLOW}šŸ’” Suggested LICENSE entries to add (grouped):{RESET}")
+        print(f"{YELLOW}{'─' * 60}{RESET}")
+        for gid in sorted(by_group):
+            print(f"  {gid}")
+            for dep in sorted(by_group[gid], key=lambda d: d.artifact_id):
+                version_display = f"{dep.version}-{dep.classifier}" if dep.classifier else dep.version
+                print(f"    {dep.artifact_id} {version_display}")
+        print()
+
+    if result.skipped_scope:
+        print(f"{CYAN}ā„¹ļø  Skipped (test/provided/system scope, not shipped):{RESET}")
+        for dep in sorted(result.skipped_scope, key=lambda d: d.coordinate):
+            print(f"  • {dep.coordinate} [{dep.scope}]")
+        print()
+
+    if not result.unmatched:
+        print(f"{GREEN}{BOLD}āœ… All runtime dependencies are properly declared in LICENSE!{RESET}")
+        print()
+
+    # Summary
+    if result.unmatched:
+        print(f"{RED}{BOLD}Result: FAIL — {len(result.unmatched)} dependency(ies) missing from LICENSE{RESET}")
+        return False
+    else:
+        print(f"{GREEN}{BOLD}Result: PASS{RESET}")
+        return True
+
+
+# ---------------------------------------------------------------------------
+# Git / GitHub PR helpers
+# ---------------------------------------------------------------------------
+#
+# The --pr flow turns an in-place LICENSE edit into a reviewable pull request:
+# after --fix rewrites the target project's LICENSE, these helpers create a
+# branch, commit only that file, push, and open a PR via the `gh` CLI. All git
+# calls go through _run_git so failures surface a uniform message.
+#
+# Design note: the branch is created BEFORE fix_license_file writes to disk,
+# so the only commit on the new branch is the LICENSE change. The commit is
+# path-scoped (git commit -- <relpath>) so unrelated dirty files in the working
+# tree — common in a monorepo with build artifacts — are never swept in.
+
+
+class GitError(RuntimeError):
+    """Raised when a git command fails with check=True."""
+
+
+def _run_git(args: List[str], cwd: str, check: bool = True,
+             timeout: int = 120) -> "subprocess.CompletedProcess":
+    """Run a git command and return the CompletedProcess.
+
+    On non-zero exit with check=True, raises GitError carrying the combined
+    stdout/stderr so the caller can print a useful message.
+    """
+    proc = subprocess.run(
+        ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=timeout
+    )
+    if check and proc.returncode != 0:
+        detail = (proc.stderr or proc.stdout or "").strip()
+        raise GitError(f"git {' '.join(args)} failed (exit {proc.returncode}): {detail}")
+    return proc
+
+
+def _find_gh() -> Optional[str]:
+    """Locate the gh executable, or None if not installed."""
+    from shutil import which
+    return which("gh")
+
+
+def find_git_root(project_dir: str) -> Optional[str]:
+    """Return the git toplevel containing project_dir, or None if not a repo."""
+    proc = _run_git(["rev-parse", "--show-toplevel"], cwd=project_dir, check=False)
+    if proc.returncode != 0:
+        return None
+    return proc.stdout.strip() or None
+
+
+def verify_remote(git_root: str, remote: str) -> bool:
+    """True if the named remote exists in the repo."""
+    return _run_git(["remote", "get-url", remote], cwd=git_root,
+                    check=False).returncode == 0
+
+
+def license_relpath(git_root: str, license_path: str) -> str:
+    """LICENSE path relative to git_root. Raises if it lies outside the repo.
+
+    Both paths are realpath-normalized first, because git reports the resolved
+    toplevel (e.g. /private/tmp on macOS where /tmp is a symlink) while the
+    caller's paths may still carry the symlink prefix — without normalization
+    a legitimately in-repo file can look like it escapes the repo.
+    """
+    root_real = os.path.realpath(git_root)
+    lic_real = os.path.realpath(license_path)
+    rel = os.path.relpath(lic_real, root_real)
+    if rel == "." or os.path.isabs(rel) or rel.startswith(".." + os.sep):
+        raise GitError(f"LICENSE file ({license_path}) is outside the git repository "
+                       f"({git_root})")
+    return rel
+
+
+def license_is_dirty(git_root: str, relpath: str) -> bool:
+    """True if the LICENSE file already has uncommitted changes."""
+    proc = _run_git(["status", "--porcelain", "--", relpath], cwd=git_root, check=False)
+    return proc.stdout.strip() != ""
+
+
+def current_ref(git_root: str) -> Tuple[str, bool]:
+    """Return (ref, is_detached). ref is the branch name, or the SHA if detached."""
+    name = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=git_root).stdout.strip()
+    if name == "HEAD":
+        sha = _run_git(["rev-parse", "HEAD"], cwd=git_root).stdout.strip()
+        return (sha, True)
+    return (name, False)
+
+
+def _branch_exists(git_root: str, branch: str) -> bool:
+    """True if a local branch named `branch` already exists."""
+    return _run_git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
+                    cwd=git_root, check=False).returncode == 0
+
+
+def unique_branch_name(git_root: str, preferred: Optional[str]) -> str:
+    """Resolve a branch name to use for the PR.
+
+    If `preferred` is given, use it as-is and error if it already exists.
+    Otherwise generate license-fix-<YYYYMMDD-HHMMSS>, suffixing -2, -3, ... until
+    a free name is found.
+    """
+    if preferred:
+        if _branch_exists(git_root, preferred):
+            raise GitError(f"branch '{preferred}' already exists; pick another "
+                           f"or omit --branch")
+        return preferred
+
+    stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
+    base = f"license-fix-{stamp}"
+    name = base
+    suffix = 1
+    while _branch_exists(git_root, name):
+        suffix += 1
+        name = f"{base}-{suffix}"
+    return name
+
+
+@dataclass
+class PRContext:
+    """State captured during PR pre-flight, carried through the commit/push/open steps."""
+    git_root: str
+    license_relpath: str
+    remote: str
+    base: Optional[str]        # None => gh uses the repo's default branch
+    branch: str                # resolved (unique) branch name
+    original_ref: str          # branch name OR sha (if detached) to restore on abort
+    is_detached: bool
+
+
+def prepare_pr_context(project_dir: str, license_path: str,
+                       branch: Optional[str], base: Optional[str],
+                       remote: str) -> PRContext:
+    """Run all read-only pre-flight checks for the --pr flow. No writes.
+
+    Exits with a clear message on any precondition failure. Does NOT create the
+    branch — that happens later, just before fix_license_file writes.
+    """
+    gh = _find_gh()
+    if not gh:
+        print("āŒ GitHub CLI (`gh`) not found. Install it from https://cli.github.com "
+              "and run `gh auth login` to use --pr.", file=sys.stderr)
+        sys.exit(2)
+
+    git_root = find_git_root(project_dir)
+    if not git_root:
+        print(f"āŒ {project_dir} is not inside a git repository; --pr requires one.",
+              file=sys.stderr)
+        sys.exit(2)
+
+    if not verify_remote(git_root, remote):
+        print(f"āŒ git remote '{remote}' not found in {git_root}.", file=sys.stderr)
+        print(f"   Add it, or pass --remote <name>.", file=sys.stderr)
+        sys.exit(2)
+
+    try:
+        rel = license_relpath(git_root, license_path)
+    except GitError as e:
+        print(f"āŒ {e}", file=sys.stderr)
+        sys.exit(2)
+
+    if license_is_dirty(git_root, rel):
+        print(f"āŒ {rel} already has uncommitted changes. Stash or commit them first.",
+              file=sys.stderr)
+        sys.exit(2)
+
+    original_ref, detached = current_ref(git_root)
+    try:
+        bname = unique_branch_name(git_root, branch)
+    except GitError as e:
+        print(f"āŒ {e}", file=sys.stderr)
+        sys.exit(2)
+
+    print(f"šŸ”§ PR target: {git_root}")
+    print(f"   branch: {bname} (from {original_ref})  remote: {remote}  "
+          f"base: {base or '<repo default>'}")
+    return PRContext(git_root=git_root, license_relpath=rel, remote=remote,
+                     base=base, branch=bname, original_ref=original_ref,
+                     is_detached=detached)
+
+
+def create_branch(ctx: PRContext) -> None:
+    """Create and check out the PR branch from the current HEAD."""
+    try:
+        _run_git(["checkout", "-b", ctx.branch], cwd=ctx.git_root)
+    except GitError as e:
+        abort_pr_branch(ctx)
+        print(f"āŒ Failed to create branch {ctx.branch}: {e}", file=sys.stderr)
+        sys.exit(2)
+
+
+def commit_license(ctx: PRContext, added_entries: List[FixEntry]) -> None:
+    """Stage and commit only the LICENSE file (path-scoped)."""
+    _run_git(["add", "--", ctx.license_relpath], cwd=ctx.git_root)
+    _run_git(["commit", "-m", _commit_message(added_entries), "--", ctx.license_relpath],
+             cwd=ctx.git_root)
+
+
+def push_branch(ctx: PRContext) -> None:
+    """Push the branch to the remote with upstream tracking (-u)."""
+    try:
+        _run_git(["push", "-u", ctx.remote, ctx.branch], cwd=ctx.git_root, timeout=180)
+    except GitError as e:
+        print(f"āŒ Push to '{ctx.remote}' failed: {e}", file=sys.stderr)
+        print(f"   If you lack push access, fork the repo "
+              f"(`gh repo fork --remote --remote-name fork`) and re-run with "
+              f"`--remote fork`.", file=sys.stderr)
+        abort_pr_branch(ctx)
+        sys.exit(2)
+
+
+def open_pr(ctx: PRContext, added_entries: List[FixEntry],
+            result: CheckResult, project_dir: str) -> str:
+    """Open a GitHub PR via `gh pr create`. Returns the PR URL."""
+    title = _pr_title(added_entries)
+    body = _pr_body(added_entries, result, project_dir)
+    cmd = ["gh", "pr", "create", "--title", title, "--body-file", "-"]
+    if ctx.base:
+        cmd += ["--base", ctx.base]
+    proc = subprocess.run(cmd, cwd=ctx.git_root, input=body,
+                          capture_output=True, text=True, timeout=120)
+    if proc.returncode != 0:
+        detail = (proc.stderr or proc.stdout or "").strip()
+        print(f"āŒ `gh pr create` failed: {detail}", file=sys.stderr)
+        print(f"   The branch {ctx.branch} is pushed; you can open the PR manually with:",
+              file=sys.stderr)
+        print(f"     gh pr create --title {title!r} --base {ctx.base or 'main'}",
+              file=sys.stderr)
+        sys.exit(2)
+    # gh prints the PR URL on the last non-empty line of stdout.
+    url = proc.stdout.strip().splitlines()[-1].strip() if proc.stdout.strip() else ""
+    return url
+
+
+def abort_pr_branch(ctx: PRContext) -> None:
+    """Best-effort cleanup: restore the original ref and delete the new branch."""
+    _run_git(["checkout", ctx.original_ref], cwd=ctx.git_root, check=False)
+    _run_git(["branch", "-D", ctx.branch], cwd=ctx.git_root, check=False)
+
+
+def _commit_message(added_entries: List[FixEntry]) -> str:
+    lines = ["chore(license): add missing dependency license declarations", ""]
+    lines.append(f"Auto-generated by shenyu-watcher (--fix --pr).")
+    lines.append("")
+    lines.append(f"Added {len(added_entries)} entr{'y' if len(added_entries) == 1 else 'ies'} to LICENSE:")
+    by_section: Dict[str, List[FixEntry]] = {}
+    for e in added_entries:
+        by_section.setdefault(e.section, []).append(e)
+    for section in sorted(by_section):
+        lines.append(f"  {section}:")
+        for e in by_section[section]:
+            lines.append(f"    {e.dep.artifact_id} {e.dep.version}")
+    return "\n".join(lines)
+
+
+def _pr_title(added_entries: List[FixEntry]) -> str:
+    n = len(added_entries)
+    return f"chore(license): add {n} missing dependency license declaration{'s' if n != 1 else ''}"
+
+
+def _pr_body(added_entries: List[FixEntry], result: CheckResult,
+             project_dir: str) -> str:
+    lines: List[str] = []
+    lines.append("## Summary")
+    lines.append("")
+    lines.append(f"This PR adds {len(added_entries)} missing dependency license "
+                 f"declaration(s) to the LICENSE file, discovered by "
+                 f"`shenyu-watcher` (pre-build license checker) while checking "
+                 f"`{os.path.basename(os.path.abspath(project_dir))}`.")
+    lines.append("")
+    lines.append("## Added entries")
+    lines.append("")
+    by_section: Dict[str, List[FixEntry]] = {}
+    for e in added_entries:
+        by_section.setdefault(e.section, []).append(e)
+    for section in sorted(by_section):
+        lines.append(f"### {section}")
+        for e in by_section[section]:
+            license_name = e.license_name or "unknown"
+            lines.append(f"- `{e.entry_line.strip()}` *({license_name})*")
+        lines.append("")
+    lines.append("## Check context")
+    lines.append("")
+    lines.append(f"- Total dependencies checked: {result.total}")
+    lines.append(f"- Matched in LICENSE (before fix): {len(result.matched)}")
+    lines.append(f"- Unmatched (before fix): {len(result.unmatched)}")
+    lines.append(f"- Entries added by this PR: {len(added_entries)}")
+    lines.append("")
+    lines.append("## Verification")
+    lines.append("")
+    lines.append("The fix was re-verified by re-running the license check after the edit.")
+    lines.append("")
+    lines.append("---")
+    lines.append("")
+    lines.append("Generated by `shenyu_watcher.py --fix --pr`.")
+    lines.append("")
+    return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def main():
+    import argparse
+
+    parser = argparse.ArgumentParser(
+        description="Check and fix Maven dependency license declarations in LICENSE file.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  python %(prog)s /path/to/project                  # Check only (lib/ auto-detected, else Maven)
+  python %(prog)s /path/to/project /path/to/LICENSE # Check with custom LICENSE path
+  python %(prog)s --fix /path/to/project            # Check and auto-fix
+  python %(prog)s -f /path/to/project               # Same as --fix
+  python %(prog)s --lib /path/to/lib /path/to/project  # Scan a specific lib/ dir of jars
+  python %(prog)s --no-lib /path/to/project         # Force Maven instead of lib/ scanning
+  python %(prog)s --pr /path/to/project             # Fix and open a GitHub PR (implies --fix)
+""",
+    )
+    parser.add_argument("project_dir", help="Path to Maven project (containing pom.xml)")
+    parser.add_argument("license_file", nargs="?", default=None,
+                        help="Path to LICENSE file (default: auto-detect)")
+    parser.add_argument("-f", "--fix", action="store_true",
+                        help="Automatically append missing entries to the LICENSE file")
+    parser.add_argument("--lib", dest="lib_dir", default=None,
+                        help="Scan JARs from this lib/ directory instead of running Maven "
+                             "(default: auto-detect <project>/target/*-bin/lib)")
+    parser.add_argument("--no-lib", action="store_true",
+                        help="Disable lib-directory scanning; force Maven dependency resolution")
+    parser.add_argument("--pr", action="store_true",
+                        help="After --fix, create a git branch, commit the LICENSE change, "
+                             "push it, and open a GitHub PR via `gh`. Implies --fix.")
+    parser.add_argument("--branch", default=None,
+                        help="Branch name for the PR (default: license-fix-<timestamp>). "
+                             "Must not already exist.")
+    parser.add_argument("--base", default=None,
+                        help="PR base branch (default: repository default branch).")
+    parser.add_argument("--remote", default="origin",
+                        help="Git remote to push the branch to (default: origin; use a fork "
+                             "remote, e.g. `--remote fork`, if you lack push access to origin)")
+
+    args = parser.parse_args()
+
+    if not os.path.isdir(args.project_dir):
+        print(f"āŒ Not a directory: {args.project_dir}", file=sys.stderr)
+        sys.exit(1)
+
+    # Run check (suppress exit on fail so we can still fix)
+    try:
+        result = run_check(args.project_dir, args.license_file,
+                           lib_dir=args.lib_dir, no_lib=args.no_lib)
+    except SystemExit as e:
+        # run_check calls sys.exit(1) on Maven failure — let it propagate
+        raise
+
+    print_result(result)
+
+    # --pr implies --fix (a PR needs a LICENSE change to exist).
+    do_fix = args.fix or args.pr
+
+    if do_fix and result.unmatched:
+        # Resolve the license path for the fix.
+        if args.license_file is not None:
+            license_path = os.path.abspath(args.license_file)
+        else:
+            license_path = _find_license_file(os.path.abspath(args.project_dir))
+            if license_path is None:
+                print(f"\nāŒ Cannot fix: LICENSE file not found.", file=sys.stderr)
+                sys.exit(1)
+
+        # PR pre-flight (read-only) and branch creation happen BEFORE the fix
+        # writes to disk, so the only commit on the new branch is the LICENSE
+        # change.
+        pr_ctx = None
+        if args.pr:
+            pr_ctx = prepare_pr_context(
+                os.path.abspath(args.project_dir), license_path,
+                branch=args.branch, base=args.base, remote=args.remote)
+            create_branch(pr_ctx)
+
+        # Write the LICENSE change (on the new branch if --pr).
+        added_entries = fix_license_file(license_path, result)
+
+        if pr_ctx is not None:
+            if not added_entries:
+                # All entries were duplicates or skipped — no LICENSE change.
+                abort_pr_branch(pr_ctx)
+                print(f"\n{YELLOW}No changes to LICENSE; PR skipped.{RESET}")
+            else:
+                commit_license(pr_ctx, added_entries)
+                push_branch(pr_ctx)
+                pr_url = open_pr(pr_ctx, added_entries, result, args.project_dir)
+                print(f"\n{GREEN}{BOLD}āœ… Pull request opened: {pr_url}{RESET}")
+                print(f"   branch: {pr_ctx.branch}  base: {pr_ctx.base or '<repo default>'}")
+
+        # Re-run check to verify
+        print(f"\n{'=' * 60}")
+        print(f"{BOLD}  Re-checking after fix...{RESET}")
+        print(f"{'=' * 60}")
+        license_content = read_license(license_path)
+        new_result = CheckResult(
+            total=result.total,
+            checked=result.checked,
+            skipped_project=result.skipped_project,
+            matched=result.matched,
+            unmatched=[],
+            skipped_scope=result.skipped_scope,
+        )
+        for dep in result.unmatched:
+            if check_dependency_in_license(dep, license_content):
+                new_result.matched.append(dep)
+            else:
+                new_result.unmatched.append(dep)
+        new_result.checked = len(new_result.matched) + len(new_result.unmatched)
+
+        if new_result.unmatched:
+            print(f"\n{YELLOW}āš ļø  {len(new_result.unmatched)} deps still unmatched after fix:{RESET}")
+            for dep in sorted(new_result.unmatched, key=lambda d: d.coordinate):
+                print(f"    • {dep.coordinate}")
+            if pr_ctx is not None:
+                print(f"\n{YELLOW}   These remain unmatched and need manual review — see the PR body.{RESET}")
+        else:
+            print(f"\n{GREEN}{BOLD}āœ… All dependencies now properly declared in LICENSE!{RESET}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/main/java/org/apache/shenyu/ShenyuWatcher.java b/src/main/java/org/apache/shenyu/ShenyuWatcher.java
index 805390e..16b1e59 100644
--- a/src/main/java/org/apache/shenyu/ShenyuWatcher.java
+++ b/src/main/java/org/apache/shenyu/ShenyuWatcher.java
@@ -17,65 +17,69 @@
 
 package org.apache.shenyu;
 
+import org.apache.commons.collections4.CollectionUtils;
 import org.apache.shenyu.entity.JarDO;
 import org.apache.shenyu.env.CheckEnv;
 import org.apache.shenyu.util.FileUtil;
 import org.apache.shenyu.util.StringUtil;
 
+import java.io.File;
 import java.util.ArrayList;
 import java.util.List;
 
 public class ShenyuWatcher {
-
+    
     public static void main(String[] args) {
-
+        
         // check py version
         System.out.println("Start to check python version...");
         if (CheckEnv.PYTHON_CHECK) {
             System.out.println("The python version check passed.");
-
+            
             String filePath = args[0];
-
-            String fileName = filePath.substring(filePath.lastIndexOf("\\") + 1);
-
+            
+            String fileName = filePath.substring(filePath.lastIndexOf(File.separator) + 1);
+            
             System.out.println("Start to unzip " + fileName + "...");
-
-            String destDir = filePath.substring(0, filePath.lastIndexOf("\\"));
+            
+            String destDir = filePath.substring(0, filePath.lastIndexOf(File.separator));
             String fileDir = fileName.replace(".tar.gz", "");
-
+            
             FileUtil.unTarGz(filePath, destDir);
             System.out.println("Decompression succeeded.");
             System.out.println("Start to check LICENSE...");
-            List<String> fileNames = FileUtil.getFileName(destDir + "\\" + fileDir + "\\lib");
-
+            List<String> fileNames = FileUtil.getFileName(destDir + File.separator + fileDir + File.separator + "lib");
+            
             JarDO jarDO = JarDO.build(fileNames);
-            String content = FileUtil.read(destDir + "\\" + fileDir + "\\LICENSE");
-
+            String content = FileUtil.read(destDir + File.separator + fileDir + File.separator + "LICENSE");
+            
             List<String> failureMatchJar = new ArrayList<>();
-
+            
             for (JarDO.ParseJar parseJar : jarDO.getParseJar()) {
-
+                
                 if (parseJar.getOriginal().contains("shenyu")) {
                     continue;
                 }
-
+                
                 if (!StringUtil.match(content, parseJar.getPackageName() + " " + parseJar.getVersion())) {
                     failureMatchJar.add(parseJar.getOriginal());
                 }
-
+                
             }
-
-            if (failureMatchJar.size() > 0) {
+            
+            if (CollectionUtils.isNotEmpty(failureMatchJar)) {
                 System.err.println("The following jars need to be modified: ");
                 failureMatchJar.forEach(System.err::println);
+            } else {
+                System.out.println("The license is ok ");
             }
-
+            
             jarDO.setFailureMatchJar(failureMatchJar);
-
+            
         } else {
             System.err.println("The python version not 3.8");
         }
-
+        
     }
-
+    
 }
diff --git a/src/main/java/org/apache/shenyu/env/CheckEnv.java b/src/main/java/org/apache/shenyu/env/CheckEnv.java
index df377f8..b821b26 100644
--- a/src/main/java/org/apache/shenyu/env/CheckEnv.java
+++ b/src/main/java/org/apache/shenyu/env/CheckEnv.java
@@ -22,6 +22,8 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * @author sinsy
@@ -29,9 +31,11 @@
  */
 public class CheckEnv {
 
-    private final static String PYTHON_VERSION = "Python 3.8";
+    private final static String REQUIRED_PYTHON_VERSION = "3.8";
 
     public static boolean PYTHON_CHECK = false;
+    
+    private static final Pattern pattern = Pattern.compile("Python (\\d+\\.\\d+\\.\\d+)");
 
     static {
         String exe = "python -V";
@@ -40,16 +44,44 @@
             process = Runtime.getRuntime().exec(exe);
             InputStream inputStream = process.getInputStream();
             String str = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
-
-            if (str.contains(PYTHON_VERSION)) {
-                PYTHON_CHECK = true;
+            
+            Matcher matcher = pattern.matcher(str);
+            if (matcher.find()) {
+                System.out.println(matcher.group(1));
+                PYTHON_CHECK = isVersionGreaterOrEqual(matcher.group(1), REQUIRED_PYTHON_VERSION);
+            }
+            
+            if (!PYTHON_CHECK) {
+                exe = "python3 -V";
+            }
+            process = Runtime.getRuntime().exec(exe);
+            inputStream = process.getInputStream();
+            str = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
+            
+            matcher = pattern.matcher(str);
+            if (matcher.find()) {
+                System.out.println(matcher.group(1));
+                PYTHON_CHECK = isVersionGreaterOrEqual(matcher.group(1), REQUIRED_PYTHON_VERSION);
             }
 
         } catch (IOException e) {
             e.printStackTrace();
         }
 
-
+    }
+    private static boolean isVersionGreaterOrEqual(String version, String requiredVersion) {
+        String[] versionParts = version.split("\\.");
+        String[] requiredVersionParts = requiredVersion.split("\\.");
+        for (int i = 0; i < requiredVersionParts.length; i++) {
+            int v = Integer.parseInt(versionParts[i]);
+            int rv = Integer.parseInt(requiredVersionParts[i]);
+            if (v > rv) {
+                return true;
+            } else if (v < rv) {
+                return false;
+            }
+        }
+        return true;
     }
 
 }