Просмотр исходного кода

Fix genre fallback source and constrain project root discovery

lingfengQAQ 4 месяцев назад
Родитель
Сommit
85da2917c4

+ 3 - 1
.claude/scripts/data_modules/context_manager.py

@@ -285,7 +285,9 @@ class ContextManager:
             return {}
 
         fallback = str(getattr(self.config, "context_genre_profile_fallback", "shuangwen") or "shuangwen")
-        genre_raw = str((state.get("project") or {}).get("genre") or fallback)
+        project = state.get("project") or {}
+        project_info = state.get("project_info") or {}
+        genre_raw = str(project.get("genre") or project_info.get("genre") or fallback)
         genres = self._parse_genre_tokens(genre_raw)
         if not genres:
             genres = [fallback]

+ 23 - 0
.claude/scripts/data_modules/tests/test_context_manager.py

@@ -526,3 +526,26 @@ def test_context_manager_dynamic_weights_from_config_override(temp_project):
 
     weights = manager._resolve_template_weights("plot", chapter=1)
     assert weights == {"core": 0.60, "scene": 0.20, "global": 0.20}
+
+
+def test_context_manager_genre_profile_fallbacks_to_project_info(temp_project):
+    manager = ContextManager(temp_project)
+
+    profile = manager._load_genre_profile({"project_info": {"genre": "xuanhuan"}})
+
+    assert profile.get("genre_raw") == "xuanhuan"
+    assert profile.get("genre") == "xuanhuan"
+
+
+def test_context_manager_genre_profile_prefers_project_over_project_info(temp_project):
+    manager = ContextManager(temp_project)
+
+    profile = manager._load_genre_profile(
+        {
+            "project": {"genre": "xuanhuan"},
+            "project_info": {"genre": "dushi"},
+        }
+    )
+
+    assert profile.get("genre_raw") == "xuanhuan"
+    assert profile.get("genre") == "xuanhuan"

+ 66 - 0
.claude/scripts/data_modules/tests/test_project_locator.py

@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import sys
+from pathlib import Path
+
+
+def _ensure_scripts_on_path() -> None:
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+def test_resolve_project_root_prefers_cwd_project(tmp_path):
+    _ensure_scripts_on_path()
+
+    from project_locator import resolve_project_root
+
+    project_root = tmp_path / "workspace"
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    resolved = resolve_project_root(cwd=project_root)
+    assert resolved == project_root.resolve()
+
+
+def test_resolve_project_root_stops_at_git_root(tmp_path):
+    _ensure_scripts_on_path()
+
+    from project_locator import resolve_project_root
+
+    repo_root = tmp_path / "repo"
+    (repo_root / ".git").mkdir(parents=True, exist_ok=True)
+
+    nested = repo_root / "sub" / "dir"
+    nested.mkdir(parents=True, exist_ok=True)
+
+    outside_project = tmp_path / "outside_project"
+    (outside_project / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (outside_project / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    try:
+        resolve_project_root(cwd=nested)
+        assert False, "Expected FileNotFoundError when only parent outside git root has project"
+    except FileNotFoundError:
+        pass
+
+
+def test_resolve_project_root_finds_default_subdir_within_git_root(tmp_path):
+    _ensure_scripts_on_path()
+
+    from project_locator import resolve_project_root
+
+    repo_root = tmp_path / "repo"
+    (repo_root / ".git").mkdir(parents=True, exist_ok=True)
+
+    default_project = repo_root / "webnovel-project"
+    (default_project / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (default_project / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    nested = repo_root / "sub" / "dir"
+    nested.mkdir(parents=True, exist_ok=True)
+
+    resolved = resolve_project_root(cwd=nested)
+    assert resolved == default_project.resolve()
+

+ 17 - 2
.claude/scripts/project_locator.py

@@ -20,7 +20,15 @@ from typing import Iterable, Optional
 DEFAULT_PROJECT_DIR_NAMES: tuple[str, ...] = ("webnovel-project",)
 
 
-def _candidate_roots(cwd: Path) -> Iterable[Path]:
+def _find_git_root(cwd: Path) -> Optional[Path]:
+    """Return nearest git root for cwd, if any."""
+    for candidate in (cwd, *cwd.parents):
+        if (candidate / ".git").exists():
+            return candidate
+    return None
+
+
+def _candidate_roots(cwd: Path, *, stop_at: Optional[Path] = None) -> Iterable[Path]:
     yield cwd
     for name in DEFAULT_PROJECT_DIR_NAMES:
         yield cwd / name
@@ -29,6 +37,8 @@ def _candidate_roots(cwd: Path) -> Iterable[Path]:
         yield parent
         for name in DEFAULT_PROJECT_DIR_NAMES:
             yield parent / name
+        if stop_at is not None and parent == stop_at:
+            break
 
 
 def _is_project_root(path: Path) -> bool:
@@ -44,6 +54,10 @@ def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Op
     2) env var WEBNOVEL_PROJECT_ROOT (if set)
     3) Search from cwd and parents, including common subdir `webnovel-project/`
 
+    Search safety:
+    - If current location is inside a Git repo, parent search stops at the repo root.
+      This avoids accidentally binding to unrelated parent directories.
+
     Raises:
         FileNotFoundError: if no valid project root can be found.
     """
@@ -61,7 +75,8 @@ def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Op
         raise FileNotFoundError(f"WEBNOVEL_PROJECT_ROOT is set but invalid (missing .webnovel/state.json): {root}")
 
     base = (cwd or Path.cwd()).resolve()
-    for candidate in _candidate_roots(base):
+    git_root = _find_git_root(base)
+    for candidate in _candidate_roots(base, stop_at=git_root):
         if _is_project_root(candidate):
             return candidate.resolve()