modern-python-guidance 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/CHANGELOG.md +13 -0
  2. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/PKG-INFO +1 -1
  3. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/pyproject.toml +1 -1
  4. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/__init__.py +1 -1
  5. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/guide_index.py +25 -0
  6. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/search.py +15 -2
  7. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/setup_cmd.py +10 -11
  8. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_guide_index.py +90 -0
  9. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_search.py +98 -2
  10. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_setup.py +39 -3
  11. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_uninstall.py +21 -0
  12. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/.github/workflows/check-python-release.yml +0 -0
  13. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/.github/workflows/ci.yml +0 -0
  14. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/.github/workflows/publish.yml +0 -0
  15. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/.gitignore +0 -0
  16. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/CONTRIBUTING.md +0 -0
  17. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/LICENSE +0 -0
  18. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/LICENSE-MIT +0 -0
  19. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/README.md +0 -0
  20. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/SECURITY.md +0 -0
  21. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/edge-cases/opus48_multiline.py +0 -0
  22. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/edge-cases/valid_alt_patterns.py +0 -0
  23. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-modern/pyproject.toml +0 -0
  24. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-modern/src/app.py +0 -0
  25. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-modern/src/config.py +0 -0
  26. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-modern/src/crawler.py +0 -0
  27. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-modern/src/models.py +0 -0
  28. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-modern/src/scanner.py +0 -0
  29. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-modern/src/utils.py +0 -0
  30. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-outdated/pyproject.toml +0 -0
  31. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-outdated/setup.py +0 -0
  32. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-outdated/src/app.py +0 -0
  33. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-outdated/src/config.py +0 -0
  34. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-outdated/src/crawler.py +0 -0
  35. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-outdated/src/models.py +0 -0
  36. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-outdated/src/scanner.py +0 -0
  37. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-a-outdated/src/utils.py +0 -0
  38. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-b-modern/myapp/models.py +0 -0
  39. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-b-modern/myapp/views.py +0 -0
  40. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-b-outdated/myapp/models.py +0 -0
  41. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-b-outdated/myapp/views.py +0 -0
  42. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-c-modern/tests/test_calculator.py +0 -0
  43. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/fixtures/variant-c-outdated/tests/test_calculator.py +0 -0
  44. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/mcp-config.json +0 -0
  45. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompt-v2.txt +0 -0
  46. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompt-v3-mcp.txt +0 -0
  47. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompt-v3.txt +0 -0
  48. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompt-v4-a.txt +0 -0
  49. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompt-v4-b.txt +0 -0
  50. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompt-v4-c.txt +0 -0
  51. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompt.txt +0 -0
  52. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompts/v5-a-detailed.txt +0 -0
  53. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompts/v5-a-normal.txt +0 -0
  54. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompts/v5-a-terse.txt +0 -0
  55. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompts/v5-b-detailed.txt +0 -0
  56. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompts/v5-b-normal.txt +0 -0
  57. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompts/v5-b-terse.txt +0 -0
  58. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompts/v5-c-detailed.txt +0 -0
  59. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompts/v5-c-normal.txt +0 -0
  60. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/prompts/v5-c-terse.txt +0 -0
  61. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/run-mcp.sh +0 -0
  62. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/run-v4.sh +0 -0
  63. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/run-v5.sh +0 -0
  64. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/run.sh +0 -0
  65. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/score-v2.sh +0 -0
  66. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/score-v3.sh +0 -0
  67. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/score-v4.sh +0 -0
  68. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/score.sh +0 -0
  69. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/score_v5.py +0 -0
  70. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/bench/test-scorer.sh +0 -0
  71. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/docs/benchmark-evaluation.md +0 -0
  72. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/docs/benchmark-procedure.md +0 -0
  73. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/docs/benchmark-v5.md +0 -0
  74. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/docs/design.md +0 -0
  75. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/rules/modern-python.md +0 -0
  76. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/SKILL.md +0 -0
  77. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
  78. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
  79. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
  80. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -0
  81. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/data-structures/dict-merge-operator.md +0 -0
  82. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/data-structures/match-case-patterns.md +0 -0
  83. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/django/django-async-views.md +0 -0
  84. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/django/django-check-constraints.md +0 -0
  85. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/django/django-json-field.md +0 -0
  86. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
  87. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +0 -0
  88. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +0 -0
  89. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/httpx/httpx-async-client-reuse.md +0 -0
  90. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
  91. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
  92. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-model-api.md +0 -0
  93. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
  94. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
  95. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/pytest/pytest-parametrize.md +0 -0
  96. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/pytest/pytest-raises-match.md +0 -0
  97. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/pytest/pytest-tmp-path.md +0 -0
  98. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-2-style.md +0 -0
  99. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-async-session.md +0 -0
  100. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-mapped-column.md +0 -0
  101. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
  102. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/stdlib/pathlib-over-os-path.md +0 -0
  103. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
  104. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/stdlib/template-strings.md +0 -0
  105. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
  106. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
  107. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/toolchain/pyproject-toml-over-setup.md +0 -0
  108. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
  109. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
  110. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
  111. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/typing/deferred-annotations.md +0 -0
  112. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
  113. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
  114. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
  115. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
  116. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
  117. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
  118. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/__main__.py +0 -0
  119. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/check.py +0 -0
  120. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/cli.py +0 -0
  121. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/compat.py +0 -0
  122. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/frontmatter.py +0 -0
  123. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/mcp_server.py +0 -0
  124. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/retrieve.py +0 -0
  125. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/uninstall_cmd.py +0 -0
  126. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/src/modern_python_guidance/version_detect.py +0 -0
  127. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_check.py +0 -0
  128. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_cli_integration.py +0 -0
  129. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_cli_unit.py +0 -0
  130. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_compat.py +0 -0
  131. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_frontmatter.py +0 -0
  132. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_guide_structure.py +0 -0
  133. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_mcp_server.py +0 -0
  134. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_mcp_unit.py +0 -0
  135. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_retrieve.py +0 -0
  136. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_scorer_v5.py +0 -0
  137. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_skill_sync.py +0 -0
  138. {modern_python_guidance-0.4.0 → modern_python_guidance-0.4.2}/tests/test_version_detect.py +0 -0
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.4.2] — 2026-06-04
6
+
7
+ ### Fixed
8
+
9
+ - `_find_project_root()` escaping to `$HOME` when `~/.claude/` exists but the repo has no `.claude/` directory. Marker search is now per-level (nearest ancestor with any marker wins) instead of per-marker-type. If you previously ran `mpg setup` and have stale symlinks at `~/.claude/skills/modern-python-guidance` or `~/.claude/rules/modern-python.md`, remove them manually. (closes #90)
10
+
11
+ ## [0.4.1] — 2026-06-03
12
+
13
+ ### Added
14
+
15
+ - Body text search indexing: API names and identifiers appearing only in guide body text (e.g. `aiter_bytes`, `from_attributes`, `serialize_timestamp`) are now discoverable via `mpg search`. Body matches score at `WEIGHT_BODY=2`, below all frontmatter weights (TAG=10, ALIAS=8, TITLE=5, CATEGORY=3), preserving existing metadata-dominant ranking. Two-tier query tokenization handles code fragments like `aiter_bytes()` and `from_attributes=True`. (closes #22)
16
+ - 25 new tests (911 total).
17
+
5
18
  ## [0.4.0] — 2026-06-03
6
19
 
7
20
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modern-python-guidance
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Version-aware BAD/GOOD pattern guides that help AI coding agents generate modern Python
5
5
  Project-URL: Homepage, https://github.com/yottayoshida/modern-python-guidance
6
6
  Project-URL: Repository, https://github.com/yottayoshida/modern-python-guidance
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "modern-python-guidance"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Version-aware BAD/GOOD pattern guides that help AI coding agents generate modern Python"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0 OR MIT"
@@ -1,3 +1,3 @@
1
1
  """Modern Python Guidance — version-aware BAD/GOOD pattern guides for AI coding agents."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.2"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import importlib.resources
6
6
  import logging
7
+ import re
7
8
  from dataclasses import dataclass, field
8
9
  from itertools import zip_longest
9
10
  from pathlib import Path
@@ -13,12 +14,17 @@ from modern_python_guidance.frontmatter import FrontmatterError, GuideMeta, pars
13
14
  log = logging.getLogger(__name__)
14
15
 
15
16
 
17
+ _DOTTED_IDENT_RE = re.compile(r"[a-zA-Z_]\w+(?:\.[a-zA-Z_]\w+)*")
18
+ _URL_RE = re.compile(r"https?://")
19
+
20
+
16
21
  @dataclass
17
22
  class Guide:
18
23
  meta: GuideMeta
19
24
  body: str
20
25
  source_path: str
21
26
  snippet: str = ""
27
+ body_tokens: frozenset[str] = field(default_factory=frozenset)
22
28
 
23
29
 
24
30
  @dataclass
@@ -71,6 +77,7 @@ def build_index(guides_dir: Path | None = None) -> GuideIndex:
71
77
  body=body,
72
78
  source_path=str(md_file),
73
79
  snippet=_extract_snippet(body),
80
+ body_tokens=_tokenize_body(body),
74
81
  )
75
82
  except FrontmatterError as e:
76
83
  log.warning("Skipping %s: %s", md_file, e)
@@ -117,6 +124,24 @@ def _code_lines(body: str, heading: str) -> list[str]:
117
124
  return lines
118
125
 
119
126
 
127
+ def _tokenize_body(body: str) -> frozenset[str]:
128
+ """Extract lowercased identifiers from guide body for search indexing."""
129
+ tokens: set[str] = set()
130
+ for line in body.splitlines():
131
+ stripped = line.strip()
132
+ if _URL_RE.search(stripped):
133
+ continue
134
+ if stripped.startswith("```"):
135
+ continue
136
+ cleaned = stripped.replace("`", "").lstrip("#").strip()
137
+ for match in _DOTTED_IDENT_RE.findall(cleaned):
138
+ lower = match.lower()
139
+ tokens.add(lower)
140
+ if "." in lower:
141
+ tokens.update(lower.split("."))
142
+ return frozenset(tokens)
143
+
144
+
120
145
  def _find_guides_dir() -> Path:
121
146
  try:
122
147
  skills_pkg = importlib.resources.files("modern_python_guidance") / "skills"
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import difflib
6
+ import re
6
7
  from dataclasses import dataclass
7
8
 
8
9
  from modern_python_guidance.compat import token_estimate, version_compatible
@@ -13,6 +14,7 @@ WEIGHT_TAG = 10
13
14
  WEIGHT_ALIAS = 8
14
15
  WEIGHT_TITLE = 5
15
16
  WEIGHT_CATEGORY = 3
17
+ WEIGHT_BODY = 2
16
18
 
17
19
  FREQ_BOOST = {"high": 1.0, "medium": 0.5, "low": 0.0}
18
20
 
@@ -45,6 +47,8 @@ def search(
45
47
  if not tokens:
46
48
  return []
47
49
 
50
+ query_idents = frozenset(re.findall(r"[a-zA-Z_]\w+", query))
51
+
48
52
  results: list[SearchResult] = []
49
53
 
50
54
  for guide_id, guide in index.guides.items():
@@ -56,7 +60,7 @@ def search(
56
60
  if python_version and not version_compatible(meta.python, python_version):
57
61
  continue
58
62
 
59
- score = _score(meta, tokens)
63
+ score = _score(meta, tokens, guide.body_tokens, query_idents)
60
64
 
61
65
  if score > 0:
62
66
  score += FREQ_BOOST.get(meta.frequency, 0.0)
@@ -84,7 +88,12 @@ def search(
84
88
  return results[:limit]
85
89
 
86
90
 
87
- def _score(meta: GuideMeta, tokens: list[str]) -> float:
91
+ def _score(
92
+ meta: GuideMeta,
93
+ tokens: list[str],
94
+ body_tokens: frozenset[str],
95
+ query_idents: frozenset[str],
96
+ ) -> float:
88
97
  score = 0.0
89
98
  tags_lower = [t.lower() for t in meta.tags]
90
99
  aliases_lower = [a.lower() for a in meta.aliases]
@@ -102,6 +111,10 @@ def _score(meta: GuideMeta, tokens: list[str]) -> float:
102
111
  if token == meta.category.lower():
103
112
  score += WEIGHT_CATEGORY
104
113
 
114
+ for ident in query_idents:
115
+ if ident in body_tokens:
116
+ score += WEIGHT_BODY
117
+
105
118
  return score
106
119
 
107
120
 
@@ -61,20 +61,19 @@ def _find_rule_source() -> Path:
61
61
 
62
62
 
63
63
  def _find_project_root(start: Path | None = None) -> Path:
64
- """Walk upward from *start* to find the project root."""
64
+ """Walk upward; return the nearest ancestor containing any marker."""
65
65
  current = (start or Path.cwd()).resolve()
66
- markers = [".claude", ".git", "pyproject.toml"]
66
+ markers = [".git", "pyproject.toml", ".claude"]
67
67
 
68
- for marker in markers:
69
- d = current
70
- while True:
71
- candidate = d / marker
72
- if candidate.exists():
68
+ d = current
69
+ while True:
70
+ for marker in markers:
71
+ if (d / marker).exists():
73
72
  return d
74
- parent = d.parent
75
- if parent == d:
76
- break
77
- d = parent
73
+ parent = d.parent
74
+ if parent == d:
75
+ break
76
+ d = parent
78
77
 
79
78
  return current
80
79
 
@@ -10,6 +10,7 @@ from modern_python_guidance.guide_index import (
10
10
  GuideIndex,
11
11
  _code_lines,
12
12
  _find_guides_dir,
13
+ _tokenize_body,
13
14
  build_index,
14
15
  )
15
16
 
@@ -216,6 +217,95 @@ class TestBuildIndex:
216
217
  # ---------------------------------------------------------------------------
217
218
 
218
219
 
220
+ class TestTokenizeBody:
221
+ def test_empty_body(self):
222
+ assert _tokenize_body("") == frozenset()
223
+
224
+ def test_extracts_identifiers(self):
225
+ body = "Use `model_dump()` instead of `.dict()`."
226
+ tokens = _tokenize_body(body)
227
+ assert "model_dump" in tokens
228
+ assert "dict" in tokens
229
+
230
+ def test_extracts_multi_word_identifiers(self):
231
+ body = "Call `field_validator` and `model_validate_json`."
232
+ tokens = _tokenize_body(body)
233
+ assert "field_validator" in tokens
234
+ assert "model_validate_json" in tokens
235
+
236
+ def test_url_lines_excluded(self):
237
+ body = (
238
+ "See https://docs.python.org/3/library/datetime.html for details.\n"
239
+ "Use `utcnow` instead."
240
+ )
241
+ tokens = _tokenize_body(body)
242
+ assert "python" not in tokens
243
+ assert "datetime" not in tokens
244
+ assert "utcnow" in tokens
245
+
246
+ def test_fence_markers_excluded_code_preserved(self):
247
+ body = "```python\naiter_bytes = True\n```\n"
248
+ tokens = _tokenize_body(body)
249
+ assert "aiter_bytes" in tokens
250
+ assert "python" not in tokens
251
+
252
+ def test_dot_split_qualified_names(self):
253
+ body = "Replace `datetime.utcnow` with `datetime.now(tz=UTC)`."
254
+ tokens = _tokenize_body(body)
255
+ assert "datetime.utcnow" in tokens
256
+ assert "datetime" in tokens
257
+ assert "utcnow" in tokens
258
+
259
+ def test_heading_markers_stripped(self):
260
+ body = "## Migration Guide\nUse `new_api` now."
261
+ tokens = _tokenize_body(body)
262
+ assert "migration" in tokens
263
+ assert "new_api" in tokens
264
+
265
+ def test_backticks_stripped(self):
266
+ body = "Use `AsyncClient` with `base_url`."
267
+ tokens = _tokenize_body(body)
268
+ assert "asyncclient" in tokens
269
+ assert "base_url" in tokens
270
+
271
+ def test_returns_frozenset(self):
272
+ result = _tokenize_body("some text")
273
+ assert isinstance(result, frozenset)
274
+
275
+ def test_single_char_identifiers_excluded(self):
276
+ body = "x = a + b"
277
+ tokens = _tokenize_body(body)
278
+ assert "x" not in tokens
279
+ assert "a" not in tokens
280
+ assert "b" not in tokens
281
+
282
+
283
+ class TestBuildIndexBodyTokens:
284
+ def test_body_tokens_populated(self, tmp_path: Path):
285
+ (tmp_path / "guide-a.md").write_text(
286
+ _make_guide_md(guide_id="guide-a", bad_code="old_api()", good_code="new_api()")
287
+ )
288
+ idx = build_index(tmp_path)
289
+ guide = idx.get("guide-a")
290
+ assert guide is not None
291
+ assert isinstance(guide.body_tokens, frozenset)
292
+ assert "old_api" in guide.body_tokens
293
+ assert "new_api" in guide.body_tokens
294
+
295
+ def test_body_tokens_default_empty(self):
296
+ meta = GuideMeta(
297
+ id="a",
298
+ title="A",
299
+ category="c",
300
+ layer=1,
301
+ tags=["t"],
302
+ python=">=3.9",
303
+ frequency="high",
304
+ )
305
+ guide = Guide(meta=meta, body="", source_path="a.md")
306
+ assert guide.body_tokens == frozenset()
307
+
308
+
219
309
  class TestCodeLines:
220
310
  def test_basic_extraction(self):
221
311
  body = "## BAD\n```python\nold()\nnew()\n```\n"
@@ -4,8 +4,9 @@ from pathlib import Path
4
4
 
5
5
  import pytest
6
6
 
7
- from modern_python_guidance.guide_index import _extract_snippet, build_index
8
- from modern_python_guidance.search import search
7
+ from modern_python_guidance.frontmatter import GuideMeta
8
+ from modern_python_guidance.guide_index import Guide, GuideIndex, _extract_snippet, build_index
9
+ from modern_python_guidance.search import WEIGHT_BODY, search
9
10
 
10
11
  GUIDES_DIR = Path(__file__).parent.parent / "skills" / "modern-python-guidance" / "guides"
11
12
 
@@ -192,6 +193,101 @@ class TestSnippetExtraction:
192
193
  assert snippet == ""
193
194
 
194
195
 
196
+ class TestBodySearch:
197
+ """Body text is indexed at WEIGHT_BODY=2, discoverable but below frontmatter."""
198
+
199
+ def test_body_only_match_aiter_bytes(self, index):
200
+ results = search(index, "aiter_bytes")
201
+ assert len(results) >= 1
202
+ assert results[0].guide_id == "httpx-streaming"
203
+ assert not results[0].fuzzy
204
+
205
+ def test_body_only_match_serialize_timestamp(self, index):
206
+ results = search(index, "serialize_timestamp")
207
+ assert len(results) >= 1
208
+ assert results[0].guide_id == "pydantic-v2-serialization"
209
+
210
+ def test_body_only_match_from_attributes(self, index):
211
+ results = search(index, "from_attributes")
212
+ ids = [r.guide_id for r in results]
213
+ assert "pydantic-v2-config" in ids
214
+
215
+ def test_code_fragment_with_parens(self, index):
216
+ results = search(index, "aiter_bytes()")
217
+ assert len(results) >= 1
218
+ assert results[0].guide_id == "httpx-streaming"
219
+
220
+ def test_code_fragment_with_equals(self, index):
221
+ results = search(index, "from_attributes=True")
222
+ ids = [r.guide_id for r in results]
223
+ assert "pydantic-v2-config" in ids
224
+
225
+ def test_body_score_below_frontmatter(self, index):
226
+ results = search(index, "typing")
227
+ assert results[0].guide_id == "use-builtin-generics"
228
+ assert results[0].score >= 10
229
+
230
+ def test_body_match_gets_frequency_boost(self, index):
231
+ results = search(index, "aiter_bytes")
232
+ r = results[0]
233
+ assert r.score > 2.0
234
+
235
+ def test_metadata_ranking_preserved(self, index):
236
+ results = search(index, "typing")
237
+ assert results[0].guide_id == "use-builtin-generics"
238
+
239
+ def test_fuzzy_fallback_still_works(self, index):
240
+ results = search(index, "genercs")
241
+ assert len(results) > 0
242
+ assert results[0].fuzzy is True
243
+
244
+
245
+ class TestBodySearchSynthetic:
246
+ """Synthetic index tests for exact score verification — no real guide dependency."""
247
+
248
+ @pytest.fixture
249
+ def synthetic_index(self):
250
+ meta = GuideMeta(
251
+ id="synth",
252
+ title="Synthetic Guide",
253
+ category="testing",
254
+ layer=1,
255
+ tags=["unrelated_tag"],
256
+ python=">=3.9",
257
+ frequency="low",
258
+ )
259
+ guide = Guide(
260
+ meta=meta,
261
+ body="",
262
+ source_path="synth.md",
263
+ body_tokens=frozenset(
264
+ ["target_api", "another_api", "datetime.utcnow", "datetime", "utcnow"]
265
+ ),
266
+ )
267
+ return GuideIndex(guides={"synth": guide})
268
+
269
+ def test_exact_body_only_score(self, synthetic_index):
270
+ results = search(synthetic_index, "target_api")
271
+ assert len(results) == 1
272
+ assert results[0].score == WEIGHT_BODY
273
+
274
+ def test_multiple_idents_score_additive(self, synthetic_index):
275
+ results = search(synthetic_index, "target_api another_api")
276
+ assert len(results) == 1
277
+ assert results[0].score == WEIGHT_BODY * 2
278
+
279
+ def test_dotted_query_matches_split_parts(self, synthetic_index):
280
+ results = search(synthetic_index, "datetime.utcnow()")
281
+ assert len(results) == 1
282
+ ids_matched = {"datetime", "utcnow"}
283
+ assert results[0].score == WEIGHT_BODY * len(ids_matched)
284
+
285
+ def test_body_match_suppresses_fuzzy(self, synthetic_index):
286
+ results = search(synthetic_index, "target_api")
287
+ assert len(results) >= 1
288
+ assert not results[0].fuzzy
289
+
290
+
195
291
  class TestEdgeCases:
196
292
  def test_empty_query(self, index):
197
293
  results = search(index, "")
@@ -59,13 +59,31 @@ class TestFindProjectRoot:
59
59
  sub.mkdir(parents=True)
60
60
  assert _find_project_root(sub) == tmp_path
61
61
 
62
- def test_claude_takes_priority_over_git(self, tmp_path: Path):
63
- """.claude/ at parent wins over .git/ at child (higher-priority marker)."""
62
+ def test_nearest_marker_wins(self, tmp_path: Path):
63
+ """Nearest ancestor with any marker wins over distant ancestor."""
64
64
  (tmp_path / ".claude").mkdir()
65
65
  child = tmp_path / "child"
66
66
  child.mkdir()
67
67
  (child / ".git").mkdir()
68
- assert _find_project_root(child) == tmp_path
68
+ assert _find_project_root(child) == child
69
+
70
+ def test_bug_repro_home_claude_escapes(self, tmp_path: Path):
71
+ """V-001: .claude at home + .git at repo -> repo wins, not home."""
72
+ home = tmp_path / "home"
73
+ (home / ".claude").mkdir(parents=True)
74
+ repo = home / "projects" / "repo"
75
+ repo.mkdir(parents=True)
76
+ (repo / ".git").mkdir()
77
+ src = repo / "src"
78
+ src.mkdir()
79
+ assert _find_project_root(src) == repo
80
+
81
+ def test_git_as_file(self, tmp_path: Path):
82
+ """V-008: .git as a file (worktree/submodule) is detected."""
83
+ (tmp_path / ".git").write_text("gitdir: /some/other/path")
84
+ sub = tmp_path / "src"
85
+ sub.mkdir()
86
+ assert _find_project_root(sub) == tmp_path
69
87
 
70
88
  def test_falls_back_to_cwd(self, tmp_path: Path):
71
89
  bare = tmp_path / "empty"
@@ -308,6 +326,24 @@ class TestSetupSkills:
308
326
  err = capsys.readouterr().err
309
327
  assert "'" in err or '"' in err
310
328
 
329
+ def test_autodetect_avoids_home_escape(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
330
+ """setup_skills auto-discovers repo root, not distant ~/.claude."""
331
+ source = self._make_source(tmp_path)
332
+ home = tmp_path / "home"
333
+ (home / ".claude").mkdir(parents=True)
334
+ repo = home / "projects" / "repo"
335
+ repo.mkdir(parents=True)
336
+ (repo / ".git").mkdir()
337
+
338
+ monkeypatch.chdir(repo)
339
+ with patch("modern_python_guidance.setup_cmd._find_skills_dir", return_value=source):
340
+ ok = setup_skills()
341
+
342
+ assert ok is True
343
+ link = repo / ".claude" / "skills" / "modern-python-guidance"
344
+ assert link.is_symlink()
345
+ assert not (home / ".claude" / "skills" / "modern-python-guidance").exists()
346
+
311
347
  def test_dry_run(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
312
348
  """V-010: dry-run does not create symlink."""
313
349
  source = self._make_source(tmp_path)
@@ -248,6 +248,27 @@ class TestUninstallSkills:
248
248
  assert link.is_symlink() # still there
249
249
  assert "Would remove" in capsys.readouterr().out
250
250
 
251
+ def test_resolves_correct_root_not_distant_ancestor(
252
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
253
+ ):
254
+ """V-009: uninstall resolves nearest project root, not distant ancestor."""
255
+ home = tmp_path / "home"
256
+ (home / ".claude" / "skills" / "modern-python-guidance").mkdir(parents=True)
257
+ repo = home / "projects" / "repo"
258
+ repo.mkdir(parents=True)
259
+ (repo / ".git").mkdir()
260
+ link = repo / ".claude" / "skills" / "modern-python-guidance"
261
+ link.parent.mkdir(parents=True)
262
+ os.symlink(tmp_path / "pkg_skills", link)
263
+
264
+ monkeypatch.chdir(repo)
265
+ ok = uninstall_skills()
266
+
267
+ assert ok is True
268
+ assert not link.is_symlink()
269
+ # Home-level skills dir must be untouched
270
+ assert (home / ".claude" / "skills" / "modern-python-guidance").is_dir()
271
+
251
272
  def test_non_symlink_path_quoted(self, tmp_path: Path, capsys):
252
273
  """V-028: rm hint uses shell-safe quoting for paths with spaces."""
253
274
  project = tmp_path / "my project"