uncoded 0.7.0__tar.gz → 0.7.1__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 (61) hide show
  1. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/namespace.yaml +16 -6
  2. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/src/uncoded/stubs.pyi +1 -1
  3. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/tests/test_cli.pyi +3 -0
  4. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/tests/test_extract.pyi +11 -2
  5. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/tests/test_serena_setup.pyi +4 -1
  6. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/tests/test_stubs.pyi +19 -4
  7. {uncoded-0.7.0 → uncoded-0.7.1}/AGENTS.md +4 -1
  8. {uncoded-0.7.0 → uncoded-0.7.1}/PKG-INFO +11 -3
  9. {uncoded-0.7.0 → uncoded-0.7.1}/README.md +9 -2
  10. {uncoded-0.7.0 → uncoded-0.7.1}/pyproject.toml +10 -0
  11. {uncoded-0.7.0 → uncoded-0.7.1}/src/uncoded/extract.py +1 -1
  12. {uncoded-0.7.0 → uncoded-0.7.1}/src/uncoded/stubs.py +7 -8
  13. {uncoded-0.7.0 → uncoded-0.7.1}/tests/test_cli.py +26 -0
  14. {uncoded-0.7.0 → uncoded-0.7.1}/tests/test_extract.py +33 -2
  15. {uncoded-0.7.0 → uncoded-0.7.1}/tests/test_serena_setup.py +21 -0
  16. {uncoded-0.7.0 → uncoded-0.7.1}/tests/test_stubs.py +238 -46
  17. uncoded-0.7.1/uv.lock +329 -0
  18. uncoded-0.7.0/uv.lock +0 -229
  19. {uncoded-0.7.0 → uncoded-0.7.1}/.agents/skills/coherence-review/SKILL.md +0 -0
  20. {uncoded-0.7.0 → uncoded-0.7.1}/.claude/settings.json +0 -0
  21. {uncoded-0.7.0 → uncoded-0.7.1}/.claude/skills/coherence-review/SKILL.md +0 -0
  22. {uncoded-0.7.0 → uncoded-0.7.1}/.github/workflows/ci.yml +0 -0
  23. {uncoded-0.7.0 → uncoded-0.7.1}/.github/workflows/publish.yml +0 -0
  24. {uncoded-0.7.0 → uncoded-0.7.1}/.gitignore +0 -0
  25. {uncoded-0.7.0 → uncoded-0.7.1}/.mcp.json +0 -0
  26. {uncoded-0.7.0 → uncoded-0.7.1}/.pre-commit-config.yaml +0 -0
  27. {uncoded-0.7.0 → uncoded-0.7.1}/.serena/project.yml +0 -0
  28. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/reviews/2026-04-25-001215.md +0 -0
  29. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/src/uncoded/__init__.pyi +0 -0
  30. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/src/uncoded/cli.pyi +0 -0
  31. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/src/uncoded/config.pyi +0 -0
  32. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/src/uncoded/extract.pyi +0 -0
  33. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/src/uncoded/instruction_files.pyi +0 -0
  34. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/src/uncoded/namespace_map.pyi +0 -0
  35. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/src/uncoded/serena_setup.pyi +0 -0
  36. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/src/uncoded/skill.pyi +0 -0
  37. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/src/uncoded/sync.pyi +0 -0
  38. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/tests/test_config.pyi +0 -0
  39. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/tests/test_instruction_files.pyi +0 -0
  40. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/tests/test_namespace_map.pyi +0 -0
  41. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/tests/test_skill.pyi +0 -0
  42. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/tests/test_sync.pyi +0 -0
  43. {uncoded-0.7.0 → uncoded-0.7.1}/.uncoded/stubs/tests/test_uncoded.pyi +0 -0
  44. {uncoded-0.7.0 → uncoded-0.7.1}/CLAUDE.md +0 -0
  45. {uncoded-0.7.0 → uncoded-0.7.1}/LICENSE +0 -0
  46. {uncoded-0.7.0 → uncoded-0.7.1}/resources/lsp-research.md +0 -0
  47. {uncoded-0.7.0 → uncoded-0.7.1}/src/uncoded/__init__.py +0 -0
  48. {uncoded-0.7.0 → uncoded-0.7.1}/src/uncoded/cli.py +0 -0
  49. {uncoded-0.7.0 → uncoded-0.7.1}/src/uncoded/config.py +0 -0
  50. {uncoded-0.7.0 → uncoded-0.7.1}/src/uncoded/instruction_files.py +0 -0
  51. {uncoded-0.7.0 → uncoded-0.7.1}/src/uncoded/namespace_map.py +0 -0
  52. {uncoded-0.7.0 → uncoded-0.7.1}/src/uncoded/serena_setup.py +0 -0
  53. {uncoded-0.7.0 → uncoded-0.7.1}/src/uncoded/skill.py +0 -0
  54. {uncoded-0.7.0 → uncoded-0.7.1}/src/uncoded/sync.py +0 -0
  55. {uncoded-0.7.0 → uncoded-0.7.1}/tests/__init__.py +0 -0
  56. {uncoded-0.7.0 → uncoded-0.7.1}/tests/test_config.py +0 -0
  57. {uncoded-0.7.0 → uncoded-0.7.1}/tests/test_instruction_files.py +0 -0
  58. {uncoded-0.7.0 → uncoded-0.7.1}/tests/test_namespace_map.py +0 -0
  59. {uncoded-0.7.0 → uncoded-0.7.1}/tests/test_skill.py +0 -0
  60. {uncoded-0.7.0 → uncoded-0.7.1}/tests/test_sync.py +0 -0
  61. {uncoded-0.7.0 → uncoded-0.7.1}/tests/test_uncoded.py +0 -0
@@ -78,7 +78,7 @@ src/:
78
78
  name:
79
79
  annotation:
80
80
  value_source:
81
- is_type_alias:
81
+ is_pep695_alias:
82
82
  StubClass:
83
83
  name:
84
84
  bases:
@@ -114,6 +114,7 @@ tests/:
114
114
  test_writes_namespace_map_stubs_and_instruction_file:
115
115
  test_idempotent_second_run:
116
116
  test_dedupes_when_claude_md_is_symlink_to_agents_md:
117
+ test_instruction_file_outside_project_uses_absolute_path:
117
118
  test_error_when_no_pyproject_toml:
118
119
  test_error_when_source_root_missing:
119
120
  test_error_when_uncoded_section_missing:
@@ -155,7 +156,7 @@ tests/:
155
156
  test_reads_configured_list:
156
157
  test_empty_list_is_respected:
157
158
  test_extract.py:
158
- TestExtractModule:
159
+ TestExtractModuleFromSource:
159
160
  test_classes_and_functions:
160
161
  test_async_functions_and_methods:
161
162
  test_empty_module:
@@ -163,10 +164,13 @@ tests/:
163
164
  test_type_alias_classic:
164
165
  test_type_alias_pep695:
165
166
  test_tuple_unpacking_skipped:
167
+ test_chained_assignment_skipped:
166
168
  test_unannotated_class_variable:
169
+ test_class_tuple_unpacking_skipped:
167
170
  test_annotated_attributes:
168
171
  test_property_classified_as_attribute:
169
172
  test_property_setter_and_deleter_suppressed:
173
+ test_non_property_decorator_classified_as_method:
170
174
  test_preserves_source_order:
171
175
  TestIterAndExtract:
172
176
  test_basic_walk:
@@ -174,7 +178,7 @@ tests/:
174
178
  test_includes_init_with_symbols:
175
179
  test_skips_empty_init:
176
180
  test_skips_syntax_errors:
177
- TestExtractModules:
181
+ TestExtractModulesFromFiles:
178
182
  test_returns_module_info_per_parseable_file:
179
183
  test_preserves_source_order:
180
184
  test_module_with_only_constants_is_kept:
@@ -232,6 +236,7 @@ tests/:
232
236
  test_merges_into_existing_claude_settings:
233
237
  test_does_not_overwrite_existing_serena_project_yml:
234
238
  test_does_not_duplicate_on_second_merge:
239
+ test_appends_missing_allowed_tool_to_existing_settings:
235
240
  test_setup_uses_root_name_when_no_pyproject:
236
241
  test_setup_reads_name_from_pyproject:
237
242
  TestRepoDogfooding:
@@ -263,7 +268,7 @@ tests/:
263
268
  test_class_with_attributes_and_methods:
264
269
  test_class_with_bases:
265
270
  test_class_no_bases:
266
- test_kwargs_and_varargs:
271
+ test_extract_params_covers_input_kind:
267
272
  test_imports_collected:
268
273
  test_syntax_error_raises:
269
274
  test_source_order_preserved:
@@ -275,6 +280,8 @@ tests/:
275
280
  test_type_alias_classic:
276
281
  test_type_alias_pep695:
277
282
  test_tuple_unpacking_skipped:
283
+ test_ann_assign_non_name_target_skipped:
284
+ test_class_tuple_unpacking_skipped:
278
285
  test_class_with_unannotated_attribute:
279
286
  test_property_rendered_as_attribute:
280
287
  test_property_without_return_annotation:
@@ -284,9 +291,9 @@ tests/:
284
291
  test_imports_rendered:
285
292
  test_rendered_stub_has_no_line_range_comments:
286
293
  test_async_function_prefix:
287
- test_function_with_annotations:
294
+ test_render_param_covers_input_kind:
295
+ test_return_annotation_rendered:
288
296
  test_class_with_bases:
289
- test_class_no_bases:
290
297
  test_class_with_no_members_renders_body:
291
298
  test_attribute_with_annotation:
292
299
  test_method_indented:
@@ -298,6 +305,7 @@ tests/:
298
305
  test_constant_bare_annotation_rendered:
299
306
  test_type_alias_pep695_rendered:
300
307
  test_unannotated_class_attribute_rendered:
308
+ test_renders_valid_python_for_representative_source:
301
309
  TestBuildStubs:
302
310
  _setup:
303
311
  _build:
@@ -309,6 +317,7 @@ tests/:
309
317
  test_no_op_when_clean:
310
318
  test_reports_count_on_first_build:
311
319
  test_reports_zero_when_clean:
320
+ test_skips_module_with_no_symbols:
312
321
  TestBuildStubsCheckMode:
313
322
  _setup:
314
323
  _build:
@@ -322,6 +331,7 @@ tests/:
322
331
  test_prunes_orphan_stubs:
323
332
  test_project_root_anchors_writes_independent_of_cwd:
324
333
  test_project_root_anchors_orphan_pruning_independent_of_cwd:
334
+ test_source_root_outside_project_root_skips_cleanup:
325
335
  test_sync.py:
326
336
  TestSyncFile:
327
337
  test_creates_missing_file:
@@ -68,7 +68,7 @@ class StubAssignment:
68
68
  name: str
69
69
  annotation: str | None = None
70
70
  value_source: str | None = None
71
- is_type_alias: bool = False
71
+ is_pep695_alias: bool = False
72
72
 
73
73
  class StubClass:
74
74
  name: str
@@ -20,6 +20,9 @@ class TestSyncApplyMode:
20
20
  def test_dedupes_when_claude_md_is_symlink_to_agents_md(self, tmp_path, monkeypatch, capsys):
21
21
  ...
22
22
 
23
+ def test_instruction_file_outside_project_uses_absolute_path(self, tmp_path, monkeypatch):
24
+ ...
25
+
23
26
  def test_error_when_no_pyproject_toml(self, tmp_path, monkeypatch, capsys):
24
27
  ...
25
28
 
@@ -3,7 +3,7 @@
3
3
  import textwrap
4
4
  from uncoded.extract import extract_module, extract_modules, iter_source_files
5
5
 
6
- class TestExtractModule:
6
+ class TestExtractModuleFromSource:
7
7
  def test_classes_and_functions(self):
8
8
  ...
9
9
 
@@ -25,9 +25,15 @@ class TestExtractModule:
25
25
  def test_tuple_unpacking_skipped(self):
26
26
  ...
27
27
 
28
+ def test_chained_assignment_skipped(self):
29
+ ...
30
+
28
31
  def test_unannotated_class_variable(self):
29
32
  ...
30
33
 
34
+ def test_class_tuple_unpacking_skipped(self):
35
+ ...
36
+
31
37
  def test_annotated_attributes(self):
32
38
  ...
33
39
 
@@ -37,6 +43,9 @@ class TestExtractModule:
37
43
  def test_property_setter_and_deleter_suppressed(self):
38
44
  ...
39
45
 
46
+ def test_non_property_decorator_classified_as_method(self):
47
+ ...
48
+
40
49
  def test_preserves_source_order(self):
41
50
  ...
42
51
 
@@ -56,7 +65,7 @@ class TestIterAndExtract:
56
65
  def test_skips_syntax_errors(self, tmp_path, capsys):
57
66
  ...
58
67
 
59
- class TestExtractModules:
68
+ class TestExtractModulesFromFiles:
60
69
  def test_returns_module_info_per_parseable_file(self):
61
70
  ...
62
71
 
@@ -3,7 +3,7 @@
3
3
  import json
4
4
  from pathlib import Path
5
5
  import yaml
6
- from uncoded.serena_setup import SERENA_ALLOWED_TOOLS, SERENA_VERSION, setup
6
+ from uncoded.serena_setup import MCP_SERVER_SERENA, SERENA_ALLOWED_TOOLS, SERENA_VERSION, setup
7
7
 
8
8
  REPO_ROOT = Path(__file__).parent.parent
9
9
  EXPECTED_EXCLUDED_TOOLS = ...
@@ -46,6 +46,9 @@ class TestSetup:
46
46
  def test_does_not_duplicate_on_second_merge(self, tmp_path):
47
47
  ...
48
48
 
49
+ def test_appends_missing_allowed_tool_to_existing_settings(self, tmp_path):
50
+ ...
51
+
49
52
  def test_setup_uses_root_name_when_no_pyproject(self, tmp_path):
50
53
  ...
51
54
 
@@ -28,7 +28,7 @@ class TestExtractStub:
28
28
  def test_class_no_bases(self):
29
29
  ...
30
30
 
31
- def test_kwargs_and_varargs(self):
31
+ def test_extract_params_covers_input_kind(self, source, expected):
32
32
  ...
33
33
 
34
34
  def test_imports_collected(self):
@@ -64,6 +64,12 @@ class TestExtractStub:
64
64
  def test_tuple_unpacking_skipped(self):
65
65
  ...
66
66
 
67
+ def test_ann_assign_non_name_target_skipped(self):
68
+ ...
69
+
70
+ def test_class_tuple_unpacking_skipped(self):
71
+ ...
72
+
67
73
  def test_class_with_unannotated_attribute(self):
68
74
  ...
69
75
 
@@ -89,13 +95,13 @@ class TestRenderStub:
89
95
  def test_async_function_prefix(self):
90
96
  ...
91
97
 
92
- def test_function_with_annotations(self):
98
+ def test_render_param_covers_input_kind(self, params, expected):
93
99
  ...
94
100
 
95
- def test_class_with_bases(self):
101
+ def test_return_annotation_rendered(self):
96
102
  ...
97
103
 
98
- def test_class_no_bases(self):
104
+ def test_class_with_bases(self):
99
105
  ...
100
106
 
101
107
  def test_class_with_no_members_renders_body(self):
@@ -131,6 +137,9 @@ class TestRenderStub:
131
137
  def test_unannotated_class_attribute_rendered(self):
132
138
  ...
133
139
 
140
+ def test_renders_valid_python_for_representative_source(self):
141
+ ...
142
+
134
143
  class TestBuildStubs:
135
144
  def _setup(self, tmp_path):
136
145
  ...
@@ -162,6 +171,9 @@ class TestBuildStubs:
162
171
  def test_reports_zero_when_clean(self, tmp_path):
163
172
  ...
164
173
 
174
+ def test_skips_module_with_no_symbols(self, tmp_path):
175
+ ...
176
+
165
177
  class TestBuildStubsCheckMode:
166
178
  def _setup(self, tmp_path):
167
179
  ...
@@ -196,3 +208,6 @@ class TestWriteStubs:
196
208
 
197
209
  def test_project_root_anchors_orphan_pruning_independent_of_cwd(self, tmp_path, monkeypatch):
198
210
  ...
211
+
212
+ def test_source_root_outside_project_root_skips_cleanup(self, tmp_path):
213
+ ...
@@ -47,8 +47,11 @@ uv run uncoded check
47
47
  # Generate Serena + ty MCP and Claude Code configuration
48
48
  uv run uncoded setup
49
49
 
50
- # Run tests
50
+ # Run tests (branch coverage enforced; see [tool.coverage.report] in pyproject.toml)
51
51
  uv run pytest
52
+
53
+ # Run a subset of tests without the coverage gate
54
+ uv run pytest tests/test_stubs.py --no-cov
52
55
  ```
53
56
 
54
57
  <!-- uncoded:start -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uncoded
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: Symbol indexes and language-server setup for AI-assisted code navigation
5
5
  Project-URL: Homepage, https://github.com/alimanfoo/uncoded
6
6
  Project-URL: Repository, https://github.com/alimanfoo/uncoded
@@ -14,6 +14,7 @@ Requires-Python: >=3.12
14
14
  Requires-Dist: pyyaml>=6.0
15
15
  Provides-Extra: dev
16
16
  Requires-Dist: pre-commit>=3.0; extra == 'dev'
17
+ Requires-Dist: pytest-cov>=6.0; extra == 'dev'
17
18
  Requires-Dist: pytest>=8.0; extra == 'dev'
18
19
  Description-Content-Type: text/markdown
19
20
 
@@ -229,13 +230,20 @@ uv sync --extra dev
229
230
  uv run pre-commit install
230
231
  ```
231
232
 
232
- Run the tests:
233
+ Run the tests (branch coverage is enforced; see `[tool.coverage.report]` in
234
+ `pyproject.toml` for the threshold):
233
235
 
234
236
  ```
235
237
  uv run pytest
236
238
  ```
237
239
 
238
- Run all checks (the same suite CI runs):
240
+ To run a subset of tests without the coverage gate:
241
+
242
+ ```
243
+ uv run pytest tests/test_stubs.py --no-cov
244
+ ```
245
+
246
+ Run the same checks CI's lint job runs:
239
247
 
240
248
  ```
241
249
  uv run pre-commit run --all-files
@@ -210,13 +210,20 @@ uv sync --extra dev
210
210
  uv run pre-commit install
211
211
  ```
212
212
 
213
- Run the tests:
213
+ Run the tests (branch coverage is enforced; see `[tool.coverage.report]` in
214
+ `pyproject.toml` for the threshold):
214
215
 
215
216
  ```
216
217
  uv run pytest
217
218
  ```
218
219
 
219
- Run all checks (the same suite CI runs):
220
+ To run a subset of tests without the coverage gate:
221
+
222
+ ```
223
+ uv run pytest tests/test_stubs.py --no-cov
224
+ ```
225
+
226
+ Run the same checks CI's lint job runs:
220
227
 
221
228
  ```
222
229
  uv run pre-commit run --all-files
@@ -25,6 +25,7 @@ classifiers = [
25
25
  dev = [
26
26
  "pre-commit>=3.0",
27
27
  "pytest>=8.0",
28
+ "pytest-cov>=6.0",
28
29
  ]
29
30
 
30
31
  [project.scripts]
@@ -42,6 +43,15 @@ source-roots = ["src", "tests"]
42
43
 
43
44
  [tool.pytest.ini_options]
44
45
  testpaths = ["tests"]
46
+ addopts = "--cov"
47
+
48
+ [tool.coverage.run]
49
+ source = ["src"]
50
+ branch = true
51
+
52
+ [tool.coverage.report]
53
+ show_missing = true
54
+ fail_under = 100
45
55
 
46
56
  [tool.ruff]
47
57
  src = ["src"]
@@ -87,7 +87,7 @@ def extract_module(source: str, rel_path: str) -> ModuleInfo:
87
87
  name = _assign_target_name(node)
88
88
  if name:
89
89
  constants.append(name)
90
- elif isinstance(node, ast.TypeAlias) and isinstance(node.name, ast.Name):
90
+ elif isinstance(node, ast.TypeAlias):
91
91
  constants.append(node.name.id)
92
92
 
93
93
  return ModuleInfo(
@@ -43,7 +43,7 @@ class StubAssignment:
43
43
  name: str
44
44
  annotation: str | None = None
45
45
  value_source: str | None = None
46
- is_type_alias: bool = False
46
+ is_pep695_alias: bool = False
47
47
 
48
48
 
49
49
  @dataclass
@@ -119,12 +119,10 @@ def _extract_assignment(
119
119
  unpacking or attribute assignment), which we can't represent cleanly.
120
120
  """
121
121
  if isinstance(node, ast.TypeAlias):
122
- if not isinstance(node.name, ast.Name):
123
- return None
124
122
  return StubAssignment(
125
123
  name=node.name.id,
126
124
  value_source=_render_value(node.value),
127
- is_type_alias=True,
125
+ is_pep695_alias=True,
128
126
  )
129
127
 
130
128
  if isinstance(node, ast.AnnAssign):
@@ -166,8 +164,6 @@ def _property_attribute(
166
164
  return StubAssignment(
167
165
  name=node.name,
168
166
  annotation=ast.unparse(node.returns) if node.returns else None,
169
- value_source=None,
170
- is_type_alias=False,
171
167
  )
172
168
 
173
169
 
@@ -250,7 +246,7 @@ def _render_function(func: StubFunction, indent: str = "") -> list[str]:
250
246
 
251
247
  def _format_assignment_body(a: StubAssignment) -> str:
252
248
  """Render the 'name [: type] [= value]' portion of an assignment."""
253
- if a.is_type_alias:
249
+ if a.is_pep695_alias:
254
250
  return f"type {a.name} = {a.value_source}"
255
251
  head = f"{a.name}: {a.annotation}" if a.annotation else a.name
256
252
  if a.value_source is None:
@@ -375,7 +371,10 @@ def _write_stubs(
375
371
  if existing.resolve() in expected:
376
372
  continue
377
373
  display = existing.relative_to(project_root)
378
- if remove_file(display, project_root=project_root, check=check):
374
+ # .pyi may have been removed between rglob and remove_file
375
+ if remove_file( # pragma: no branch
376
+ display, project_root=project_root, check=check
377
+ ):
379
378
  changes += 1
380
379
 
381
380
  if check:
@@ -103,6 +103,32 @@ class TestSyncApplyMode:
103
103
  ]
104
104
  assert instruction_lines == ["Updated AGENTS.md"]
105
105
 
106
+ def test_instruction_file_outside_project_uses_absolute_path(
107
+ self, tmp_path, monkeypatch
108
+ ):
109
+ project = tmp_path / "project"
110
+ project.mkdir()
111
+ (project / "src").mkdir()
112
+ (project / "pyproject.toml").write_text(
113
+ textwrap.dedent(
114
+ """\
115
+ [project]
116
+ name = "demo"
117
+
118
+ [tool.uncoded]
119
+ source-roots = ["src"]
120
+ instruction-files = ["../outside.md"]
121
+ """
122
+ )
123
+ )
124
+ monkeypatch.chdir(project)
125
+
126
+ assert cli._sync() == 0
127
+
128
+ outside_file = tmp_path / "outside.md"
129
+ assert outside_file.exists()
130
+ assert "<!-- uncoded:start -->" in outside_file.read_text()
131
+
106
132
  def test_error_when_no_pyproject_toml(self, tmp_path, monkeypatch, capsys):
107
133
  # Pins the problem statement, the recovery hint pointing at
108
134
  # [tool.uncoded] source-roots, and the absence of any absolute
@@ -3,7 +3,7 @@ import textwrap
3
3
  from uncoded.extract import extract_module, extract_modules, iter_source_files
4
4
 
5
5
 
6
- class TestExtractModule:
6
+ class TestExtractModuleFromSource:
7
7
  def test_classes_and_functions(self):
8
8
  source = textwrap.dedent("""\
9
9
  class MyClass:
@@ -99,6 +99,13 @@ class TestExtractModule:
99
99
 
100
100
  assert result.constants == []
101
101
 
102
+ def test_chained_assignment_skipped(self):
103
+ source = "x = y = 1\n"
104
+
105
+ result = extract_module(source, "chain.py")
106
+
107
+ assert result.constants == []
108
+
102
109
  def test_unannotated_class_variable(self):
103
110
  source = textwrap.dedent("""\
104
111
  class Registry:
@@ -112,6 +119,16 @@ class TestExtractModule:
112
119
  cls = result.classes[0]
113
120
  assert cls.attributes == ["items", "_cache", "count"]
114
121
 
122
+ def test_class_tuple_unpacking_skipped(self):
123
+ source = textwrap.dedent("""\
124
+ class C:
125
+ a, b = 1, 2
126
+ """)
127
+
128
+ result = extract_module(source, "c.py")
129
+
130
+ assert result.classes[0].attributes == []
131
+
115
132
  def test_annotated_attributes(self):
116
133
  source = textwrap.dedent("""\
117
134
  from dataclasses import dataclass
@@ -172,6 +189,20 @@ class TestExtractModule:
172
189
  assert cls.attributes == ["path"]
173
190
  assert cls.methods == []
174
191
 
192
+ def test_non_property_decorator_classified_as_method(self):
193
+ source = textwrap.dedent("""\
194
+ class Util:
195
+ @staticmethod
196
+ def helper():
197
+ pass
198
+ """)
199
+
200
+ result = extract_module(source, "util.py")
201
+
202
+ cls = result.classes[0]
203
+ assert cls.methods == ["helper"]
204
+ assert cls.attributes == []
205
+
175
206
  def test_preserves_source_order(self):
176
207
  source = textwrap.dedent("""\
177
208
  def zebra():
@@ -284,7 +315,7 @@ class TestIterAndExtract:
284
315
  assert "SyntaxError" in err
285
316
 
286
317
 
287
- class TestExtractModules:
318
+ class TestExtractModulesFromFiles:
288
319
  def test_returns_module_info_per_parseable_file(self):
289
320
  files = [
290
321
  ("def foo(): pass\n", "src/a.py"),
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  import yaml
5
5
 
6
6
  from uncoded.serena_setup import (
7
+ MCP_SERVER_SERENA,
7
8
  SERENA_ALLOWED_TOOLS,
8
9
  SERENA_VERSION,
9
10
  setup,
@@ -173,6 +174,26 @@ class TestSetup:
173
174
  for tool in SERENA_ALLOWED_TOOLS:
174
175
  assert claude["permissions"]["allow"].count(tool) == 1
175
176
 
177
+ def test_appends_missing_allowed_tool_to_existing_settings(self, tmp_path):
178
+ (tmp_path / ".mcp.json").write_text(
179
+ json.dumps({"mcpServers": {"serena": MCP_SERVER_SERENA}})
180
+ )
181
+ claude_path = tmp_path / ".claude" / "settings.json"
182
+ claude_path.parent.mkdir()
183
+ missing_tool = SERENA_ALLOWED_TOOLS[-1]
184
+ claude_path.write_text(
185
+ json.dumps(
186
+ {
187
+ "enabledMcpjsonServers": ["serena"],
188
+ "permissions": {"allow": list(SERENA_ALLOWED_TOOLS[:-1])},
189
+ }
190
+ )
191
+ )
192
+ self._run(tmp_path)
193
+ data = json.loads(claude_path.read_text())
194
+ assert missing_tool in data["permissions"]["allow"]
195
+ assert set(data["permissions"]["allow"]) >= set(SERENA_ALLOWED_TOOLS)
196
+
176
197
  def test_setup_uses_root_name_when_no_pyproject(self, tmp_path):
177
198
  # No ``pyproject.toml`` anywhere from ``tmp_path`` up to the
178
199
  # filesystem root, so ``read_project_name(start=tmp_path)`` falls