modern-python-guidance 0.4.1__tar.gz → 0.4.3__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.1 → modern_python_guidance-0.4.3}/CHANGELOG.md +18 -0
  2. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/PKG-INFO +1 -1
  3. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/pyproject.toml +1 -1
  4. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/__init__.py +1 -1
  5. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/check.py +22 -0
  6. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/compat.py +2 -2
  7. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/frontmatter.py +9 -1
  8. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/mcp_server.py +24 -1
  9. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/setup_cmd.py +10 -11
  10. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_check.py +56 -0
  11. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_compat.py +12 -0
  12. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_frontmatter.py +43 -0
  13. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_guide_structure.py +9 -0
  14. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_mcp_server.py +31 -0
  15. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_mcp_unit.py +112 -0
  16. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_setup.py +39 -3
  17. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_uninstall.py +21 -0
  18. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/.github/workflows/check-python-release.yml +0 -0
  19. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/.github/workflows/ci.yml +0 -0
  20. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/.github/workflows/publish.yml +0 -0
  21. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/.gitignore +0 -0
  22. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/CONTRIBUTING.md +0 -0
  23. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/LICENSE +0 -0
  24. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/LICENSE-MIT +0 -0
  25. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/README.md +0 -0
  26. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/SECURITY.md +0 -0
  27. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/edge-cases/opus48_multiline.py +0 -0
  28. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/edge-cases/valid_alt_patterns.py +0 -0
  29. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-modern/pyproject.toml +0 -0
  30. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-modern/src/app.py +0 -0
  31. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-modern/src/config.py +0 -0
  32. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-modern/src/crawler.py +0 -0
  33. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-modern/src/models.py +0 -0
  34. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-modern/src/scanner.py +0 -0
  35. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-modern/src/utils.py +0 -0
  36. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-outdated/pyproject.toml +0 -0
  37. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-outdated/setup.py +0 -0
  38. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-outdated/src/app.py +0 -0
  39. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-outdated/src/config.py +0 -0
  40. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-outdated/src/crawler.py +0 -0
  41. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-outdated/src/models.py +0 -0
  42. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-outdated/src/scanner.py +0 -0
  43. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-a-outdated/src/utils.py +0 -0
  44. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-b-modern/myapp/models.py +0 -0
  45. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-b-modern/myapp/views.py +0 -0
  46. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-b-outdated/myapp/models.py +0 -0
  47. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-b-outdated/myapp/views.py +0 -0
  48. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-c-modern/tests/test_calculator.py +0 -0
  49. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/fixtures/variant-c-outdated/tests/test_calculator.py +0 -0
  50. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/mcp-config.json +0 -0
  51. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompt-v2.txt +0 -0
  52. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompt-v3-mcp.txt +0 -0
  53. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompt-v3.txt +0 -0
  54. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompt-v4-a.txt +0 -0
  55. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompt-v4-b.txt +0 -0
  56. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompt-v4-c.txt +0 -0
  57. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompt.txt +0 -0
  58. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompts/v5-a-detailed.txt +0 -0
  59. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompts/v5-a-normal.txt +0 -0
  60. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompts/v5-a-terse.txt +0 -0
  61. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompts/v5-b-detailed.txt +0 -0
  62. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompts/v5-b-normal.txt +0 -0
  63. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompts/v5-b-terse.txt +0 -0
  64. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompts/v5-c-detailed.txt +0 -0
  65. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompts/v5-c-normal.txt +0 -0
  66. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/prompts/v5-c-terse.txt +0 -0
  67. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/run-mcp.sh +0 -0
  68. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/run-v4.sh +0 -0
  69. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/run-v5.sh +0 -0
  70. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/run.sh +0 -0
  71. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/score-v2.sh +0 -0
  72. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/score-v3.sh +0 -0
  73. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/score-v4.sh +0 -0
  74. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/score.sh +0 -0
  75. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/score_v5.py +0 -0
  76. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/bench/test-scorer.sh +0 -0
  77. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/docs/benchmark-evaluation.md +0 -0
  78. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/docs/benchmark-procedure.md +0 -0
  79. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/docs/benchmark-v5.md +0 -0
  80. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/docs/design.md +0 -0
  81. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/rules/modern-python.md +0 -0
  82. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/SKILL.md +0 -0
  83. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
  84. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
  85. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
  86. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -0
  87. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/data-structures/dict-merge-operator.md +0 -0
  88. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/data-structures/match-case-patterns.md +0 -0
  89. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/django/django-async-views.md +0 -0
  90. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/django/django-check-constraints.md +0 -0
  91. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/django/django-json-field.md +0 -0
  92. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
  93. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +0 -0
  94. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +0 -0
  95. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/httpx/httpx-async-client-reuse.md +0 -0
  96. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
  97. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
  98. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-model-api.md +0 -0
  99. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
  100. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
  101. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/pytest/pytest-parametrize.md +0 -0
  102. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/pytest/pytest-raises-match.md +0 -0
  103. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/pytest/pytest-tmp-path.md +0 -0
  104. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-2-style.md +0 -0
  105. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-async-session.md +0 -0
  106. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-mapped-column.md +0 -0
  107. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
  108. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/stdlib/pathlib-over-os-path.md +0 -0
  109. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
  110. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/stdlib/template-strings.md +0 -0
  111. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
  112. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
  113. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/toolchain/pyproject-toml-over-setup.md +0 -0
  114. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
  115. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
  116. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
  117. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/typing/deferred-annotations.md +0 -0
  118. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
  119. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
  120. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
  121. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
  122. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
  123. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
  124. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/__main__.py +0 -0
  125. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/cli.py +0 -0
  126. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/guide_index.py +0 -0
  127. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/retrieve.py +0 -0
  128. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/search.py +0 -0
  129. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/uninstall_cmd.py +0 -0
  130. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/src/modern_python_guidance/version_detect.py +0 -0
  131. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_cli_integration.py +0 -0
  132. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_cli_unit.py +0 -0
  133. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_guide_index.py +0 -0
  134. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_retrieve.py +0 -0
  135. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_scorer_v5.py +0 -0
  136. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_search.py +0 -0
  137. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_skill_sync.py +0 -0
  138. {modern_python_guidance-0.4.1 → modern_python_guidance-0.4.3}/tests/test_version_detect.py +0 -0
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.4.3] — 2026-06-04
6
+
7
+ ### Fixed
8
+
9
+ - MCP server crash on malformed `params` and `arguments`: non-dict values now return JSON-RPC -32602 instead of `TypeError`. `serve()` catch-all returns -32603 on unexpected errors. Notification messages (no `id`) are silently dropped per JSON-RPC 2.0 spec. (closes #91)
10
+ - `mpg check` false positives in multi-line docstrings: `check_file()` now uses `tokenize` to identify multi-line string token ranges and skips those lines. Single-line strings on code lines are still scanned. Tokenize failure (syntax errors, indentation errors) falls back to scanning all lines. (closes #92)
11
+ - Invalid PEP 440 specifiers in guide `python:` field silently treated as all-version compatible: `_build_meta()` now validates with `SpecifierSet` at parse time, raising `FrontmatterError`. Runtime `version_compatible()` narrows except from `(InvalidSpecifier, Exception)` to `(InvalidSpecifier, InvalidVersion)`. (closes #93)
12
+
13
+ ### Added
14
+
15
+ - 53 new tests (987 total).
16
+
17
+ ## [0.4.2] — 2026-06-04
18
+
19
+ ### Fixed
20
+
21
+ - `_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)
22
+
5
23
  ## [0.4.1] — 2026-06-03
6
24
 
7
25
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modern-python-guidance
3
- Version: 0.4.1
3
+ Version: 0.4.3
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.1"
7
+ version = "0.4.3"
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.1"
3
+ __version__ = "0.4.3"
@@ -2,7 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import io
5
6
  import re
7
+ import tokenize as _tokenize
6
8
  from dataclasses import dataclass
7
9
  from pathlib import Path
8
10
 
@@ -33,6 +35,23 @@ class CheckMatch:
33
35
  snippet: str
34
36
 
35
37
 
38
+ def _string_lines(text: str) -> frozenset[int]:
39
+ """Line numbers belonging to multi-line STRING tokens (docstrings etc.)."""
40
+ skip: set[int] = set()
41
+ try:
42
+ tokens = _tokenize.generate_tokens(io.StringIO(text).readline)
43
+ string_types = {_tokenize.STRING}
44
+ fstring_mid = getattr(_tokenize, "FSTRING_MIDDLE", None)
45
+ if fstring_mid is not None:
46
+ string_types.add(fstring_mid)
47
+ for tok in tokens:
48
+ if tok.type in string_types and tok.end[0] > tok.start[0]:
49
+ skip.update(range(tok.start[0], tok.end[0] + 1))
50
+ except (_tokenize.TokenError, SyntaxError):
51
+ return frozenset()
52
+ return frozenset(skip)
53
+
54
+
36
55
  def check_file(
37
56
  path: Path,
38
57
  index: GuideIndex,
@@ -48,8 +67,11 @@ def check_file(
48
67
  if not patterns:
49
68
  return []
50
69
 
70
+ skip = _string_lines(text)
51
71
  matches: list[CheckMatch] = []
52
72
  for lineno, line in enumerate(text.splitlines(), 1):
73
+ if lineno in skip:
74
+ continue
53
75
  stripped = line.strip()
54
76
  if not stripped or stripped.startswith("#"):
55
77
  continue
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import re
6
6
 
7
7
  from packaging.specifiers import InvalidSpecifier, SpecifierSet
8
- from packaging.version import Version
8
+ from packaging.version import InvalidVersion, Version
9
9
 
10
10
  VERSION_RE = re.compile(r"^\d+\.\d+$")
11
11
 
@@ -14,7 +14,7 @@ def version_compatible(guide_python: str, target: str) -> bool:
14
14
  try:
15
15
  spec = SpecifierSet(guide_python)
16
16
  return Version(f"{target}.0") in spec
17
- except (InvalidSpecifier, Exception):
17
+ except (InvalidSpecifier, InvalidVersion):
18
18
  return True
19
19
 
20
20
 
@@ -13,6 +13,8 @@ import re
13
13
  from dataclasses import dataclass, field
14
14
  from typing import Any
15
15
 
16
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
17
+
16
18
  _KEY_RE = re.compile(r"^([a-z][a-z0-9_-]*)\s*:\s*(.*)")
17
19
  _LIST_ITEM_RE = re.compile(r"^ - (.+)")
18
20
  _FENCE = "---"
@@ -123,6 +125,12 @@ def _build_meta(raw: dict[str, Any]) -> GuideMeta:
123
125
  if isinstance(raw[str_field], list):
124
126
  raise FrontmatterError(f"'{str_field}' must be a scalar value, not a list")
125
127
 
128
+ python_value = str(raw["python"])
129
+ try:
130
+ SpecifierSet(python_value)
131
+ except InvalidSpecifier as e:
132
+ raise FrontmatterError(f"invalid python specifier '{python_value}': {e}") from e
133
+
126
134
  freq = raw["frequency"]
127
135
  if freq not in VALID_FREQUENCIES:
128
136
  raise FrontmatterError(f"invalid frequency '{freq}', must be one of {VALID_FREQUENCIES}")
@@ -171,7 +179,7 @@ def _build_meta(raw: dict[str, Any]) -> GuideMeta:
171
179
  category=str(raw["category"]),
172
180
  layer=layer,
173
181
  tags=[str(t) for t in tags],
174
- python=str(raw["python"]),
182
+ python=python_value,
175
183
  frequency=freq,
176
184
  aliases=[str(a) for a in aliases_raw],
177
185
  pep=pep,
@@ -347,6 +347,15 @@ def _handle_request(msg: dict) -> dict | None:
347
347
  params = msg.get("params", {})
348
348
  is_notification = "id" not in msg
349
349
 
350
+ if not isinstance(params, dict):
351
+ if not is_notification:
352
+ return _error_response(
353
+ req_id,
354
+ -32602,
355
+ f"Invalid params: expected object, got {type(params).__name__}",
356
+ )
357
+ return None
358
+
350
359
  if method == "notifications/initialized":
351
360
  return None
352
361
 
@@ -368,6 +377,13 @@ def _handle_request(msg: dict) -> dict | None:
368
377
  if method == "tools/call":
369
378
  tool_name = params.get("name", "")
370
379
  arguments = params.get("arguments", {})
380
+ if not isinstance(arguments, dict):
381
+ result = _error_response(
382
+ req_id,
383
+ -32602,
384
+ f"Invalid arguments: expected object, got {type(arguments).__name__}",
385
+ )
386
+ return None if is_notification else result
371
387
  tool_result = _handle_tool_call(tool_name, arguments)
372
388
  result = _result_response(req_id, tool_result)
373
389
  return None if is_notification else result
@@ -390,7 +406,14 @@ def serve(*, stdin: object = None, stdout: object = None) -> None:
390
406
  if msg is None:
391
407
  break
392
408
 
393
- response = _handle_request(msg)
409
+ try:
410
+ response = _handle_request(msg)
411
+ except Exception:
412
+ log.exception("Unexpected error handling request")
413
+ if isinstance(msg, dict) and "id" in msg:
414
+ response = _error_response(msg.get("id"), -32603, "Internal error")
415
+ else:
416
+ continue
394
417
  if response is not None:
395
418
  _write_message(response, stdout)
396
419
 
@@ -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
 
@@ -276,6 +276,62 @@ class TestEdgeCases:
276
276
  assert len(patterns) == 0
277
277
 
278
278
 
279
+ class TestStringLineFiltering:
280
+ def test_docstring_not_matched(self, tmp_path: Path, index: GuideIndex):
281
+ p = tmp_path / "docstring.py"
282
+ p.write_text(
283
+ "def example():\n"
284
+ ' """Use datetime.utcnow() for timestamps.\n'
285
+ "\n"
286
+ " Also from typing import List is common.\n"
287
+ ' """\n'
288
+ " return 1\n",
289
+ encoding="utf-8",
290
+ )
291
+ matches = check_file(p, index)
292
+ assert matches == []
293
+
294
+ def test_inline_string_on_code_line_still_matched(self, tmp_path: Path, index: GuideIndex):
295
+ p = tmp_path / "inline.py"
296
+ p.write_text(
297
+ 'from typing import List\nx = "some string"\n',
298
+ encoding="utf-8",
299
+ )
300
+ matches = check_file(p, index)
301
+ ids = {m.guide_id for m in matches}
302
+ assert "use-builtin-generics" in ids
303
+
304
+ def test_tokenize_failure_falls_back(self, tmp_path: Path, index: GuideIndex):
305
+ p = tmp_path / "broken.py"
306
+ p.write_text(
307
+ 'from typing import List\nx = """unterminated\n',
308
+ encoding="utf-8",
309
+ )
310
+ matches = check_file(p, index)
311
+ ids = {m.guide_id for m in matches}
312
+ assert "use-builtin-generics" in ids
313
+
314
+ def test_indentation_error_falls_back(self, tmp_path: Path, index: GuideIndex):
315
+ p = tmp_path / "indent.py"
316
+ p.write_text(
317
+ "from typing import List\nif True:\n x = 1\n y = 2\n",
318
+ encoding="utf-8",
319
+ )
320
+ matches = check_file(p, index)
321
+ ids = {m.guide_id for m in matches}
322
+ assert "use-builtin-generics" in ids
323
+
324
+ def test_single_line_string_not_skipped(self, tmp_path: Path, index: GuideIndex):
325
+ p = tmp_path / "singleline.py"
326
+ p.write_text(
327
+ "from typing import List # 'example'\n",
328
+ encoding="utf-8",
329
+ )
330
+ matches = check_file(p, index)
331
+ ids = {m.guide_id for m in matches}
332
+ assert "use-builtin-generics" in ids
333
+
334
+
279
335
  class TestFreqRank:
280
336
  def test_all_frequencies_covered(self):
281
337
  assert "high" in FREQ_RANK
@@ -33,6 +33,18 @@ class TestVersionCompatible:
33
33
  def test_patch_level_target(self):
34
34
  assert version_compatible(">=3.9", "3.11.1") is True
35
35
 
36
+ def test_unexpected_error_propagates(self, monkeypatch):
37
+ def _boom(self, *a, **kw):
38
+ raise RuntimeError("unexpected")
39
+
40
+ fake = type("Boom", (), {"__init__": _boom, "__contains__": _boom})
41
+ monkeypatch.setattr(
42
+ "modern_python_guidance.compat.SpecifierSet",
43
+ fake,
44
+ )
45
+ with pytest.raises(RuntimeError, match="unexpected"):
46
+ version_compatible(">=3.9", "3.12")
47
+
36
48
 
37
49
  class TestTokenEstimate:
38
50
  def test_empty_string(self):
@@ -346,6 +346,49 @@ Body.
346
346
  parse_frontmatter(text)
347
347
 
348
348
 
349
+ def test_invalid_python_specifier_rejected():
350
+ text = """\
351
+ ---
352
+ id: bad-spec
353
+ title: Bad Specifier
354
+ category: typing
355
+ layer: 1
356
+ tags:
357
+ - test
358
+ python: "not-a-valid-specifier"
359
+ frequency: high
360
+ ---
361
+
362
+ Body.
363
+ """
364
+ with pytest.raises(FrontmatterError, match="invalid python specifier"):
365
+ parse_frontmatter(text)
366
+
367
+
368
+ @pytest.mark.parametrize(
369
+ "specifier",
370
+ [">=3.9", ">=3.11", ">=3.9,<3.13", ">=3.11,<3.14", ""],
371
+ ids=["ge39", "ge311", "range39_13", "range311_14", "empty"],
372
+ )
373
+ def test_valid_python_specifiers_accepted(specifier: str):
374
+ text = f"""\
375
+ ---
376
+ id: valid-spec
377
+ title: Valid Specifier
378
+ category: typing
379
+ layer: 1
380
+ tags:
381
+ - test
382
+ python: "{specifier}"
383
+ frequency: high
384
+ ---
385
+
386
+ Body.
387
+ """
388
+ meta, _ = parse_frontmatter(text)
389
+ assert meta.python == specifier
390
+
391
+
349
392
  def test_detect_patterns_non_list_rejected():
350
393
  text = """\
351
394
  ---
@@ -210,6 +210,15 @@ class TestDetectPatterns:
210
210
  )
211
211
 
212
212
 
213
+ class TestPythonSpecifiers:
214
+ def test_all_guides_have_valid_specifiers(self, guide_file: Path):
215
+ from packaging.specifiers import SpecifierSet
216
+
217
+ text = guide_file.read_text(encoding="utf-8")
218
+ meta, _ = parse_frontmatter(text)
219
+ SpecifierSet(meta.python)
220
+
221
+
213
222
  class TestGuideInventory:
214
223
  def test_no_duplicate_ids(self):
215
224
  seen: dict[str, Path] = {}
@@ -386,6 +386,37 @@ class TestProtocol:
386
386
  assert result["isError"] is True
387
387
 
388
388
 
389
+ class TestMalformedParams:
390
+ def test_non_dict_params_returns_error_and_server_continues(self):
391
+ responses = _run_mcp(
392
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": "bad"},
393
+ {"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}},
394
+ )
395
+ assert len(responses) == 2
396
+ assert responses[0]["error"]["code"] == -32602
397
+ assert responses[0]["id"] == 1
398
+ assert "expected object" in responses[0]["error"]["message"]
399
+ assert "protocolVersion" in responses[1]["result"]
400
+
401
+ def test_non_dict_arguments_returns_error_and_server_continues(self):
402
+ responses = _run_mcp(
403
+ *_init_handshake(),
404
+ {
405
+ "jsonrpc": "2.0",
406
+ "id": 1,
407
+ "method": "tools/call",
408
+ "params": {"name": "search_guides", "arguments": "not-a-dict"},
409
+ },
410
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
411
+ )
412
+ error = responses[1].get("error")
413
+ assert error is not None
414
+ assert error["code"] == -32602
415
+ assert "expected object" in error["message"]
416
+ assert responses[2]["id"] == 2
417
+ assert "tools" in responses[2]["result"]
418
+
419
+
389
420
  class TestStdoutPollution:
390
421
  def test_no_non_jsonrpc_output(self):
391
422
  stdin_data = _build_session(
@@ -362,6 +362,49 @@ class TestHandleRequest:
362
362
  assert resp["error"]["code"] == -32600
363
363
  assert "expected JSON object" in resp["error"]["message"]
364
364
 
365
+ @pytest.mark.parametrize(
366
+ "params",
367
+ [None, "hello", "", [1, 2], 42, 0, 3.14, True, False],
368
+ ids=["none", "string", "empty-string", "list", "int", "zero", "float", "true", "false"],
369
+ )
370
+ def test_non_dict_params_returns_invalid_params(self, params):
371
+ msg = {"jsonrpc": "2.0", "id": 10, "method": "initialize", "params": params}
372
+ resp = mcp._handle_request(msg)
373
+ assert resp["jsonrpc"] == "2.0"
374
+ assert resp["id"] == 10
375
+ assert resp["error"]["code"] == -32602
376
+ assert "expected object" in resp["error"]["message"]
377
+
378
+ def test_non_dict_params_notification_returns_none(self):
379
+ msg = {"jsonrpc": "2.0", "method": "initialize", "params": "bad"}
380
+ assert mcp._handle_request(msg) is None
381
+
382
+ def test_non_dict_arguments_returns_invalid_params(self):
383
+ msg = {
384
+ "jsonrpc": "2.0",
385
+ "id": 11,
386
+ "method": "tools/call",
387
+ "params": {"name": "search_guides", "arguments": "bad"},
388
+ }
389
+ resp = mcp._handle_request(msg)
390
+ assert resp["id"] == 11
391
+ assert resp["error"]["code"] == -32602
392
+ assert "expected object" in resp["error"]["message"]
393
+
394
+ def test_non_dict_arguments_notification_returns_none(self):
395
+ msg = {
396
+ "jsonrpc": "2.0",
397
+ "method": "tools/call",
398
+ "params": {"name": "search_guides", "arguments": [1, 2]},
399
+ }
400
+ assert mcp._handle_request(msg) is None
401
+
402
+ def test_unknown_method_with_bad_params_returns_invalid_params(self):
403
+ """Centralized params check runs before method dispatch."""
404
+ msg = {"jsonrpc": "2.0", "id": 12, "method": "nonexistent", "params": 42}
405
+ resp = mcp._handle_request(msg)
406
+ assert resp["error"]["code"] == -32602
407
+
365
408
 
366
409
  # --- serve ---
367
410
 
@@ -421,3 +464,72 @@ class TestServe:
421
464
  assert responses[0]["id"] is None
422
465
  assert responses[1]["id"] == 1
423
466
  assert "protocolVersion" in responses[1]["result"]
467
+
468
+ def test_non_dict_params_recovery(self):
469
+ lines = (
470
+ json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": "bad"})
471
+ + "\n"
472
+ + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}})
473
+ + "\n"
474
+ )
475
+ sin = io.StringIO(lines)
476
+ sout = io.StringIO()
477
+ mcp.serve(stdin=sin, stdout=sout)
478
+ responses = [json.loads(line) for line in sout.getvalue().strip().split("\n")]
479
+ assert len(responses) == 2
480
+ assert responses[0]["error"]["code"] == -32602
481
+ assert responses[0]["id"] == 1
482
+ assert responses[1]["id"] == 2
483
+ assert "protocolVersion" in responses[1]["result"]
484
+
485
+ def test_catch_all_returns_internal_error(self, monkeypatch):
486
+ def boom(msg):
487
+ raise RuntimeError("unexpected")
488
+
489
+ monkeypatch.setattr(mcp, "_handle_request", boom)
490
+ req = {"jsonrpc": "2.0", "id": 99, "method": "initialize", "params": {}}
491
+ sin = io.StringIO(json.dumps(req) + "\n")
492
+ sout = io.StringIO()
493
+ mcp.serve(stdin=sin, stdout=sout)
494
+ resp = json.loads(sout.getvalue().strip())
495
+ assert resp["id"] == 99
496
+ assert resp["error"]["code"] == -32603
497
+ assert resp["error"]["message"] == "Internal error"
498
+
499
+ def test_catch_all_notification_no_response(self, monkeypatch):
500
+ def boom(msg):
501
+ raise RuntimeError("unexpected")
502
+
503
+ monkeypatch.setattr(mcp, "_handle_request", boom)
504
+ req = {"jsonrpc": "2.0", "method": "initialize", "params": {}}
505
+ sin = io.StringIO(json.dumps(req) + "\n")
506
+ sout = io.StringIO()
507
+ mcp.serve(stdin=sin, stdout=sout)
508
+ assert sout.getvalue() == ""
509
+
510
+ def test_catch_all_then_recovery(self, monkeypatch):
511
+ call_count = 0
512
+
513
+ original = mcp._handle_request
514
+
515
+ def boom_once(msg):
516
+ nonlocal call_count
517
+ call_count += 1
518
+ if call_count == 1:
519
+ raise RuntimeError("first call fails")
520
+ return original(msg)
521
+
522
+ monkeypatch.setattr(mcp, "_handle_request", boom_once)
523
+ lines = (
524
+ json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
525
+ + "\n"
526
+ + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}})
527
+ + "\n"
528
+ )
529
+ sin = io.StringIO(lines)
530
+ sout = io.StringIO()
531
+ mcp.serve(stdin=sin, stdout=sout)
532
+ responses = [json.loads(line) for line in sout.getvalue().strip().split("\n")]
533
+ assert len(responses) == 2
534
+ assert responses[0]["error"]["code"] == -32603
535
+ assert "protocolVersion" in responses[1]["result"]
@@ -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"