victor-codegraph 0.10.0__tar.gz → 0.10.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 (48) hide show
  1. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/PKG-INFO +33 -3
  2. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/README.md +32 -2
  3. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/pyproject.toml +1 -1
  4. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_adapter.py +9 -0
  5. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_adapter_validation_v2.py +27 -0
  6. victor_codegraph-0.10.2/tests/test_diagnostics_v2.py +46 -0
  7. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_relations_v2.py +36 -0
  8. victor_codegraph-0.10.2/tests/test_repository_delta_v2.py +128 -0
  9. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_treesitter_jsts.py +35 -0
  10. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/__init__.py +1 -1
  11. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/adapter.py +19 -4
  12. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/parser.py +18 -10
  13. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/python_parser.py +39 -17
  14. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/repository_index.py +75 -16
  15. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/resolution.py +9 -1
  16. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/treesitter_parser.py +3 -1
  17. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph.egg-info/PKG-INFO +33 -3
  18. victor_codegraph-0.10.0/tests/test_diagnostics_v2.py +0 -26
  19. victor_codegraph-0.10.0/tests/test_repository_delta_v2.py +0 -46
  20. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/setup.cfg +0 -0
  21. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_chunk_contract_v2.py +0 -0
  22. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_embedding_contract.py +0 -0
  23. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_identity_v2.py +0 -0
  24. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_line_base_contract.py +0 -0
  25. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_manifest.py +0 -0
  26. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_python_parser.py +0 -0
  27. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_repo.py +0 -0
  28. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_sizing_and_chunk.py +0 -0
  29. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_sizing_overlap.py +0 -0
  30. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_symbol_oid_contract.py +0 -0
  31. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_symbol_oid_parity.py +0 -0
  32. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_treesitter_languages.py +0 -0
  33. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/tests/test_treesitter_relations.py +0 -0
  34. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/config.py +0 -0
  35. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/embeddings.py +0 -0
  36. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/javascript_repository.py +0 -0
  37. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/languages.py +0 -0
  38. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/manifest.py +0 -0
  39. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/model.py +0 -0
  40. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/py.typed +0 -0
  41. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/repo.py +0 -0
  42. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/rust_repository.py +0 -0
  43. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/sizing.py +0 -0
  44. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph/treesitter_tables.py +0 -0
  45. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph.egg-info/SOURCES.txt +0 -0
  46. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph.egg-info/dependency_links.txt +0 -0
  47. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph.egg-info/requires.txt +0 -0
  48. {victor_codegraph-0.10.0 → victor_codegraph-0.10.2}/victor_codegraph.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: victor-codegraph
3
- Version: 0.10.0
3
+ Version: 0.10.2
4
4
  Summary: Repository-to-Code-Semantic-Graph compiler with stable identity, bounded chunks, incremental deltas, and ProximaRecord projection.
5
5
  Author-email: Vijaykumar Singh <vijay@anvaiops.com>
6
6
  License: Apache-2.0
@@ -56,7 +56,11 @@ gaps:
56
56
  ## Install
57
57
 
58
58
  Published on PyPI as [`victor-codegraph`](https://pypi.org/project/victor-codegraph/).
59
- This source tree is version **0.10.0**; consumers should use a compatible `<1.0` pin.
59
+ This source tree is version **0.10.2**; consumers should use a compatible `<1.0` pin.
60
+ It repairs nested Python definition/import shadowing: proven local definitions
61
+ retain their targets, while unmodeled conditional bindings stay unresolved.
62
+ Rebuild derived graphs when adopting the new parser; unchanged source hashes do
63
+ not invalidate old semantic results automatically. Publication is a separate gate.
60
64
 
61
65
  ```bash
62
66
  # from PyPI
@@ -128,10 +132,36 @@ records = to_proxima_records(repository, repo_graph_id="myrepo")
128
132
 
129
133
  ## Status
130
134
 
131
- `0.10.0` source version. Python (`ast`) is the full, offline frontend. JS/TS, Go, Rust,
135
+ `0.10.2` source version. Python (`ast`) is the full, offline frontend. JS/TS, Go, Rust,
132
136
  Java, C, and C++ have conformance fixtures; other mapped tree-sitter languages report
133
137
  symbols-tier or fallback status rather than implying full fidelity.
134
138
 
139
+ ### Ambiguous declarations
140
+
141
+ Duplicate structural identities retain raw declarations in `ParsedRepository.files`
142
+ and report each conflicting source location. Repository projection emits only the
143
+ canonical FILE marker for that file, with typed diagnostics, declaration spans and
144
+ omitted symbol/relation counts in metadata. Calls into its declarations remain
145
+ unresolved; healthy imports can still point to its diagnostic marker. Repairing the
146
+ source restores normal projection through deletion-complete repository deltas.
147
+ Direct record projection rejects duplicate identities before invoking an embedder,
148
+ including when legacy OIDs are selected.
149
+
150
+ `identity_collisions` groups use the existing `symbol_id` with `resolution: ambiguous`.
151
+ Each declaration retains `name`, `qualified_name`, `symbol_type` and its full
152
+ `source_span`; these are inspectable evidence, not separately resolved graph nodes.
153
+ The record adapter forwards these groups, projection status and omitted counts only
154
+ when present, without copying arbitrary symbol metadata into exported properties.
155
+
156
+ ### TypeScript grammar limitation
157
+
158
+ The current tree-sitter grammar reports a partial parse for inline import types
159
+ in generic calls, for example `load<typeof import('child_process')>()`, even though
160
+ TypeScript accepts that syntax. A named type alias (`type ChildProcessModule =
161
+ typeof import('child_process')`, then `load<ChildProcessModule>()`) is supported.
162
+ Partial status and syntax diagnostics remain visible; the parser does not rewrite
163
+ source or infer missing relationships.
164
+
135
165
  ### Rust graph fidelity
136
166
 
137
167
  Rust indexing retains inline module scopes and discriminates same-named trait
@@ -25,7 +25,11 @@ gaps:
25
25
  ## Install
26
26
 
27
27
  Published on PyPI as [`victor-codegraph`](https://pypi.org/project/victor-codegraph/).
28
- This source tree is version **0.10.0**; consumers should use a compatible `<1.0` pin.
28
+ This source tree is version **0.10.2**; consumers should use a compatible `<1.0` pin.
29
+ It repairs nested Python definition/import shadowing: proven local definitions
30
+ retain their targets, while unmodeled conditional bindings stay unresolved.
31
+ Rebuild derived graphs when adopting the new parser; unchanged source hashes do
32
+ not invalidate old semantic results automatically. Publication is a separate gate.
29
33
 
30
34
  ```bash
31
35
  # from PyPI
@@ -97,10 +101,36 @@ records = to_proxima_records(repository, repo_graph_id="myrepo")
97
101
 
98
102
  ## Status
99
103
 
100
- `0.10.0` source version. Python (`ast`) is the full, offline frontend. JS/TS, Go, Rust,
104
+ `0.10.2` source version. Python (`ast`) is the full, offline frontend. JS/TS, Go, Rust,
101
105
  Java, C, and C++ have conformance fixtures; other mapped tree-sitter languages report
102
106
  symbols-tier or fallback status rather than implying full fidelity.
103
107
 
108
+ ### Ambiguous declarations
109
+
110
+ Duplicate structural identities retain raw declarations in `ParsedRepository.files`
111
+ and report each conflicting source location. Repository projection emits only the
112
+ canonical FILE marker for that file, with typed diagnostics, declaration spans and
113
+ omitted symbol/relation counts in metadata. Calls into its declarations remain
114
+ unresolved; healthy imports can still point to its diagnostic marker. Repairing the
115
+ source restores normal projection through deletion-complete repository deltas.
116
+ Direct record projection rejects duplicate identities before invoking an embedder,
117
+ including when legacy OIDs are selected.
118
+
119
+ `identity_collisions` groups use the existing `symbol_id` with `resolution: ambiguous`.
120
+ Each declaration retains `name`, `qualified_name`, `symbol_type` and its full
121
+ `source_span`; these are inspectable evidence, not separately resolved graph nodes.
122
+ The record adapter forwards these groups, projection status and omitted counts only
123
+ when present, without copying arbitrary symbol metadata into exported properties.
124
+
125
+ ### TypeScript grammar limitation
126
+
127
+ The current tree-sitter grammar reports a partial parse for inline import types
128
+ in generic calls, for example `load<typeof import('child_process')>()`, even though
129
+ TypeScript accepts that syntax. A named type alias (`type ChildProcessModule =
130
+ typeof import('child_process')`, then `load<ChildProcessModule>()`) is supported.
131
+ Partial status and syntax diagnostics remain visible; the parser does not rewrite
132
+ source or infer missing relationships.
133
+
104
134
  ### Rust graph fidelity
105
135
 
106
136
  Rust indexing retains inline module scopes and discriminates same-named trait
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "victor-codegraph"
7
- version = "0.10.0"
7
+ version = "0.10.2"
8
8
  description = "Repository-to-Code-Semantic-Graph compiler with stable identity, bounded chunks, incremental deltas, and ProximaRecord projection."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -26,6 +26,15 @@ def test_symbol_records_shape():
26
26
  assert n["props"]["lang"] == "python"
27
27
  assert n["props"]["ast_kind"] in ("FUNCTION", "METHOD", "CONSTRUCTOR")
28
28
  assert n["embeddings"] == [] # no embedder supplied
29
+ assert (
30
+ not {
31
+ "identity_collisions",
32
+ "projection_status",
33
+ "omitted_symbol_count",
34
+ "omitted_relation_count",
35
+ }
36
+ & n["props"].keys()
37
+ )
29
38
 
30
39
 
31
40
  def test_edge_records_reference_node_oids():
@@ -40,3 +40,30 @@ def test_legacy_oid_mode_keeps_edge_endpoints_attached():
40
40
  edge = next(r for r in records if "graph_edge" in r["labels"])
41
41
  assert edge["edge"]["from_oid"] in nodes
42
42
  assert edge["edge"]["to_oid"] in nodes
43
+
44
+
45
+ @pytest.mark.parametrize("stable_oid", [True, False])
46
+ @pytest.mark.parametrize("embedding_mode", ["embedder", "batch_embedder"])
47
+ def test_duplicate_symbol_ids_fail_before_embedding(stable_oid, embedding_mode):
48
+ from unittest.mock import Mock
49
+
50
+ parsed = parse("def f(): return 1\ndef f(): return 2\n", file_path="duplicate.py")
51
+ embedder = Mock(
52
+ return_value=[0.0, 0.0] if embedding_mode == "embedder" else [[0.0, 0.0], [0.0, 0.0]]
53
+ )
54
+ with pytest.raises(ValueError, match="identity collision"):
55
+ to_proxima_records(
56
+ parsed, "repo", stable_oid=stable_oid, dim=2, **{embedding_mode: embedder}
57
+ )
58
+ embedder.assert_not_called()
59
+
60
+
61
+ def test_symbol_alias_cannot_rebind_another_canonical_identity():
62
+ from unittest.mock import Mock
63
+
64
+ parsed = parse("def a(): pass\ndef b(): pass\n", file_path="aliases.py")
65
+ parsed.symbols[1].legacy_id = parsed.symbols[0].id
66
+ embedder = Mock(return_value=[0.0, 0.0])
67
+ with pytest.raises(ValueError, match="alias identity collision"):
68
+ to_proxima_records(parsed, "repo", embedder=embedder, dim=2)
69
+ embedder.assert_not_called()
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from victor_codegraph import CapabilityTier, ChunkConfig, ParseStatus, parse
4
+
5
+
6
+ def test_syntax_error_reports_fallback_diagnostic():
7
+ parsed = parse("def broken(:\n", file_path="bad.py")
8
+ assert parsed.status == ParseStatus.FALLBACK
9
+ assert parsed.diagnostics
10
+ assert parsed.diagnostics[0].code == "syntax_error"
11
+
12
+
13
+ def test_unknown_language_reports_fallback_instead_of_silent_success():
14
+ parsed = parse("plain text", language="unknown", file_path="x.unknown")
15
+ assert parsed.status == ParseStatus.FALLBACK
16
+ assert parsed.capability_tier == CapabilityTier.FALLBACK
17
+ assert parsed.diagnostics
18
+
19
+
20
+ def test_extract_relations_config_is_effective():
21
+ parsed = parse(
22
+ "def f():\n return g()\n",
23
+ file_path="m.py",
24
+ config=ChunkConfig(extract_relations=False),
25
+ )
26
+ assert parsed.relations == []
27
+
28
+
29
+ def test_shadowed_python_declarations_remain_explicit_identity_collisions():
30
+ parsed = parse(
31
+ "class C:\n @property\n def value(self): return 1\n"
32
+ " @property\n def value(self): return 2\n",
33
+ file_path="duplicate.py",
34
+ )
35
+ assert parsed.status == ParseStatus.PARTIAL
36
+ assert [(item.code, item.severity) for item in parsed.diagnostics] == [
37
+ ("identity_collision", "error"),
38
+ ("identity_collision", "error"),
39
+ ]
40
+ assert [(item.line, item.column) for item in parsed.diagnostics] == [(2, 4), (4, 4)]
41
+ definitions = [symbol for symbol in parsed.symbols if symbol.simple_name == "value"]
42
+ assert len(definitions) == 2
43
+ assert definitions[0].id == definitions[1].id
44
+ assert definitions[0].legacy_id != definitions[1].legacy_id
45
+ assert all("duplicate.py::C::value" in item.message for item in parsed.diagnostics)
46
+ assert all(definitions[0].id in item.message for item in parsed.diagnostics)
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from pathlib import Path
4
+
3
5
  import pytest
4
6
 
5
7
  from victor_codegraph import CodeRelationType, parse, parse_repo, to_proxima_records
@@ -319,6 +321,40 @@ def test_python_enclosing_and_capture_bindings_block_import_guess(tmp_path, body
319
321
  assert calls and all(r.target_ref for r in calls)
320
322
 
321
323
 
324
+ @pytest.mark.parametrize(
325
+ ("body", "target_scope"),
326
+ [
327
+ ("def caller():\n def target(): return 2\n return target()", ["caller"]),
328
+ ("async def caller():\n async def target(): return 2\n return await target()", ["caller"]),
329
+ ("def caller():\n class target: pass\n return target()", ["caller"]),
330
+ ("def outer():\n def target(): return 2\n def caller(): return target()", ["outer"]),
331
+ ("def caller(flag):\n if flag:\n def target(): return 2\n return target()", None),
332
+ (
333
+ "def outer():\n def target(): return 2\n def caller(flag):\n if flag:\n def target(): return 3\n return target()",
334
+ None,
335
+ ),
336
+ ("def caller(): return target()", []),
337
+ ],
338
+ )
339
+ def test_python_nearest_definition_precedes_import(
340
+ tmp_path: Path, body: str, target_scope: list[str] | None
341
+ ) -> None:
342
+ (tmp_path / "a.py").write_text("def target(): return 1\n")
343
+ (tmp_path / "b.py").write_text("from a import target\n" + body)
344
+ graph = parse_repo(tmp_path)
345
+ calls = [r for r in graph.relations if r.relation_type == CodeRelationType.CALLS]
346
+ assert len(calls) == 1
347
+ call = calls[0]
348
+ if target_scope is None:
349
+ assert call.target_ref is not None
350
+ assert call.confidence < 1.0
351
+ else:
352
+ target = next(s for s in graph.symbols if s.id == call.to_symbol_id)
353
+ assert call.target_ref is None
354
+ assert target.scope_chain == target_scope
355
+ assert target.location.file_path == ("b.py" if target_scope else "a.py")
356
+
357
+
322
358
  def test_python_reassigned_self_is_not_a_proven_receiver():
323
359
  parsed = parse(
324
360
  "class A:\n def target(self): pass\n def caller(self):\n self = external\n self.target()\n",
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+
5
+ import pytest
6
+
7
+ from victor_codegraph import apply_index_delta, diff_repository, parse_repo
8
+
9
+
10
+ def test_repository_delta_deletes_removed_and_replaced_symbols(tmp_path):
11
+ a = tmp_path / "a.py"
12
+ b = tmp_path / "b.py"
13
+ a.write_text("def old_name():\n return 1\n")
14
+ b.write_text("def removed():\n return 2\n")
15
+ previous = parse_repo(tmp_path, repo_id="repo")
16
+
17
+ a.write_text("def new_name():\n return 1\n")
18
+ b.unlink()
19
+ current = parse_repo(tmp_path, repo_id="repo")
20
+ delta = diff_repository(current, previous)
21
+
22
+ old_function_ids = {s.id for s in previous.symbols if s.symbol_type.name == "FUNCTION"}
23
+ assert old_function_ids <= set(delta.delete_symbol_ids)
24
+ assert "new_name" in {s.simple_name for s in delta.upsert_symbols}
25
+
26
+ rebuilt = apply_index_delta(previous, delta)
27
+ assert {s.id for s in rebuilt.symbols} == {s.id for s in current.symbols}
28
+ assert rebuilt.manifest == current.manifest
29
+ assert delta.base_generation_id == previous.generation_id
30
+ assert delta.target_generation_id == current.generation_id
31
+
32
+ with pytest.raises(ValueError, match="base generation"):
33
+ apply_index_delta(replace(previous, generation_id="stale"), delta)
34
+
35
+
36
+ def test_repository_delta_refreshes_relation_evidence_on_line_shift(tmp_path):
37
+ path = tmp_path / "m.py"
38
+ path.write_text("def a():\n return b()\n\ndef b(): return 1\n")
39
+ previous = parse_repo(tmp_path, repo_id="repo")
40
+ path.write_text("\ndef a():\n return b()\n\ndef b(): return 1\n")
41
+ current = parse_repo(tmp_path, repo_id="repo")
42
+
43
+ delta = diff_repository(current, previous)
44
+ assert delta.upsert_relations
45
+ rebuilt = apply_index_delta(previous, delta)
46
+ assert rebuilt.relations[0].call_site.start_line == current.relations[0].call_site.start_line
47
+
48
+
49
+ def test_repository_quarantines_identity_collisions_and_delta_recovers(tmp_path):
50
+ from victor_codegraph import CodeRelationType, CodeSymbolType, ParseStatus
51
+
52
+ ambiguous = tmp_path / "duplicate.py"
53
+ ambiguous.write_text("def helper(): return 1\n")
54
+ (tmp_path / "caller.py").write_text(
55
+ "from duplicate import helper\ndef caller(): return helper()\n"
56
+ )
57
+ previous = parse_repo(tmp_path, repo_id="repo")
58
+ ambiguous.write_text(
59
+ "def f(): return helper()\ndef f(): return helper()\ndef helper(): return 1\n"
60
+ )
61
+ current = parse_repo(tmp_path, repo_id="repo")
62
+ raw = current.files["duplicate.py"]
63
+ assert raw.status == ParseStatus.PARTIAL
64
+ assert len(raw.symbols) == 3 # Keep original evidence, including both definitions.
65
+ projected = [s for s in current.symbols if s.location.file_path == "duplicate.py"]
66
+ assert len(projected) == 1
67
+ marker = projected[0]
68
+ assert marker.symbol_type == CodeSymbolType.FILE
69
+ assert marker.metadata["parse_status"] == "partial"
70
+ assert marker.metadata["projection_status"] == "file_marker_only"
71
+ assert marker.metadata["omitted_symbol_count"] == 3
72
+ assert marker.metadata["omitted_relation_count"] == len(raw.relations)
73
+ assert [d["line"] for d in marker.metadata["diagnostics"]] == [1, 2]
74
+ collisions = marker.metadata["identity_collisions"]
75
+ assert len(collisions) == 1 and collisions[0]["symbol_id"] == raw.symbols[0].id
76
+ assert collisions[0]["resolution"] == "ambiguous"
77
+ assert [d["qualified_name"] for d in collisions[0]["declarations"]] == [
78
+ "duplicate.py::f",
79
+ "duplicate.py::f",
80
+ ]
81
+ assert [d["symbol_type"] for d in collisions[0]["declarations"]] == ["function", "function"]
82
+ assert [d["source_span"]["start_line"] for d in collisions[0]["declarations"]] == [1, 2]
83
+ from victor_codegraph import to_proxima_records
84
+
85
+ marker.metadata["private_source_metadata"] = "must not be exported"
86
+ records = to_proxima_records(current, repo_graph_id="repo")
87
+ record = next(
88
+ r
89
+ for r in records
90
+ if r.get("props", {}).get("file") == "duplicate.py"
91
+ and r.get("props", {}).get("ast_kind") == "FILE"
92
+ )
93
+ for key in (
94
+ "identity_collisions",
95
+ "projection_status",
96
+ "omitted_symbol_count",
97
+ "omitted_relation_count",
98
+ ):
99
+ assert record["props"][key] == marker.metadata[key]
100
+ assert "private_source_metadata" not in record["props"]
101
+ assert not any(
102
+ "/external/" not in r["edge"]["to_oid"]
103
+ for r in records
104
+ if "graph_edge" in r["labels"] and r["edge"]["edge_type"] == "CALLS"
105
+ )
106
+ assert len({s.id for s in current.symbols}) == len(current.symbols)
107
+ assert not any(r.from_symbol_id == marker.id for r in current.relations)
108
+ calls = [r for r in current.relations if r.relation_type == CodeRelationType.CALLS]
109
+ assert len(calls) == 1 and calls[0].target_ref.name == "helper"
110
+ assert any(
111
+ r.relation_type == CodeRelationType.IMPORTS and r.to_symbol_id == marker.id
112
+ for r in current.relations
113
+ )
114
+ delta = diff_repository(current, previous)
115
+ assert delta.delete_symbol_ids and delta.delete_relation_keys
116
+ rebuilt = apply_index_delta(previous, delta)
117
+ assert {s.id for s in rebuilt.symbols} == {s.id for s in current.symbols}
118
+
119
+ ambiguous.write_text("def helper(): return 2\n")
120
+ repaired = parse_repo(tmp_path, repo_id="repo")
121
+ rebuilt = apply_index_delta(current, diff_repository(repaired, current))
122
+ assert {s.id for s in rebuilt.symbols} == {s.id for s in repaired.symbols}
123
+ calls = [r for r in rebuilt.relations if r.relation_type == CodeRelationType.CALLS]
124
+ assert len(calls) == 1 and calls[0].target_ref is None
125
+ assert (
126
+ next(s for s in repaired.symbols if s.id == marker.id).metadata["projection_status"]
127
+ == "symbols"
128
+ )
@@ -75,3 +75,38 @@ def test_chunk_jsts_is_size_capped():
75
75
  if not parsed.symbols:
76
76
  pytest.skip("javascript grammar not installed")
77
77
  assert parsed.symbols # routed through tree-sitter, not the empty stub
78
+
79
+
80
+ @pytest.mark.parametrize(
81
+ "annotation",
82
+ [
83
+ "export type X<T> = T extends object ? T : never;",
84
+ "type Options = Record<string, object>;",
85
+ "function accept(value: object): object { return value; }",
86
+ ],
87
+ )
88
+ def test_typescript_object_keyword_is_not_an_object_literal_scope(annotation):
89
+ source = annotation + "\nconst handlers = { run() { return 1; } };"
90
+ parsed = parse(source, file_path="case.ts")
91
+ shifted = parse("// heading\n\n" + source, file_path="case.ts")
92
+ assert parsed.status.value == "success"
93
+ assert not parsed.diagnostics
94
+ methods = [symbol for symbol in parsed.symbols if symbol.simple_name == "run"]
95
+ assert len(methods) == 1
96
+ assert methods[0].scope_chain
97
+ assert {symbol.id for symbol in parsed.symbols} == {symbol.id for symbol in shifted.symbols}
98
+
99
+
100
+ def test_inline_import_generic_grammar_limitation_stays_explicit():
101
+ # The pinned grammar rejects this valid TypeScript import type in a generic
102
+ # call. Do not hide the partial parse; an equivalent named alias is supported.
103
+ inline = parse("const value = load<typeof import('child_process')>();", file_path="case.ts")
104
+ assert inline.status.value == "partial"
105
+ assert "syntax_error" in {diagnostic.code for diagnostic in inline.diagnostics}
106
+ aliased = parse(
107
+ "type ChildProcessModule = typeof import('child_process');"
108
+ "const value = load<ChildProcessModule>();",
109
+ file_path="case.ts",
110
+ )
111
+ assert aliased.status.value == "success"
112
+ assert not aliased.diagnostics
@@ -54,7 +54,7 @@ from .repository_index import (
54
54
  relation_key,
55
55
  )
56
56
 
57
- __version__ = "0.10.0"
57
+ __version__ = "0.10.2"
58
58
 
59
59
  __all__ = [
60
60
  "__version__",
@@ -145,6 +145,15 @@ def symbol_to_record(
145
145
  },
146
146
  "embeddings": [],
147
147
  }
148
+ # Preserve compiler-owned ambiguity evidence without exposing arbitrary metadata.
149
+ for key in (
150
+ "identity_collisions",
151
+ "projection_status",
152
+ "omitted_symbol_count",
153
+ "omitted_relation_count",
154
+ ):
155
+ if key in symbol.metadata:
156
+ record["props"][key] = symbol.metadata[key]
148
157
  if embedder is not None and not symbol.metadata.get("embedding_excluded"):
149
158
  record["embeddings"].append(
150
159
  {
@@ -241,14 +250,24 @@ def to_proxima_records(
241
250
 
242
251
  id_map: dict[str, str] = {}
243
252
  use_stable = _stable_oid_enabled(stable_oid)
253
+ seen_symbols: set[str] = set()
254
+ seen_oids: set[str] = set()
244
255
  for symbol in parsed.symbols:
245
256
  primary = (
246
257
  _canonical_symbol_oid(repo_graph_id, symbol)
247
258
  if use_stable
248
259
  else _legacy_symbol_oid(repo_graph_id, symbol)
249
260
  )
261
+ # Validate before invoking either embedding callback. A legacy OID
262
+ # preference must not hide ambiguous canonical symbol identities.
263
+ if symbol.id in seen_symbols or primary in seen_oids:
264
+ raise ValueError("canonical symbol identity collision in adapter input")
265
+ seen_symbols.add(symbol.id)
266
+ seen_oids.add(primary)
250
267
  for key in (symbol.id, symbol.legacy_id):
251
268
  if key:
269
+ if key in id_map and id_map[key] != primary:
270
+ raise ValueError("symbol alias identity collision in adapter input")
252
271
  id_map[key] = primary
253
272
  records = [
254
273
  symbol_to_record(
@@ -286,10 +305,6 @@ def to_proxima_records(
286
305
  "values": _validate_vector(values, dim),
287
306
  }
288
307
  )
289
- by_oid = [r["oid"] for r in records]
290
- if len(by_oid) != len(set(by_oid)):
291
- raise ValueError("canonical symbol identity collision in adapter input")
292
-
293
308
  external_records: dict[str, dict[str, Any]] = {}
294
309
  for relation in parsed.relations:
295
310
  if relation.target_ref is None:
@@ -16,6 +16,7 @@ from .model import (
16
16
  CapabilityTier,
17
17
  CodeChunk,
18
18
  CodeRelationType,
19
+ CodeSymbol,
19
20
  CodeSymbolType,
20
21
  ParseDiagnostic,
21
22
  ParsedCode,
@@ -52,7 +53,7 @@ def _finalize_identity(parsed: ParsedCode, content: str) -> ParsedCode:
52
53
  """Promote parser-local line IDs to the v2 structural identity contract."""
53
54
 
54
55
  id_map: dict[str, str] = {}
55
- seen: dict[str, str] = {}
56
+ groups: dict[str, list[CodeSymbol]] = {}
56
57
  for symbol in parsed.symbols:
57
58
  legacy = symbol.legacy_id or symbol.id
58
59
  stable = stable_symbol_key(
@@ -62,22 +63,29 @@ def _finalize_identity(parsed: ParsedCode, content: str) -> ParsedCode:
62
63
  symbol.signature,
63
64
  symbol.location.file_path,
64
65
  )
65
- other = seen.get(stable)
66
- if other is not None and other != legacy:
67
- parsed.status = ParseStatus.PARTIAL
66
+ groups.setdefault(stable, []).append(symbol)
67
+ id_map[legacy] = stable
68
+ symbol.legacy_id = legacy
69
+ symbol.id = stable
70
+ symbol.identity_version = "v2"
71
+
72
+ for stable, definitions in groups.items():
73
+ if len(definitions) < 2:
74
+ continue
75
+ parsed.status = ParseStatus.PARTIAL
76
+ for symbol in definitions:
68
77
  parsed.diagnostics.append(
69
78
  ParseDiagnostic(
70
79
  code="identity_collision",
71
- message=f"multiple symbols share structural key {stable}",
80
+ message=(
81
+ f"multiple declarations of {symbol.fully_qualified_name!r} "
82
+ f"share structural key {stable}"
83
+ ),
72
84
  severity="error",
73
85
  line=symbol.location.start_line,
86
+ column=symbol.location.start_column,
74
87
  )
75
88
  )
76
- seen[stable] = legacy
77
- id_map[legacy] = stable
78
- symbol.legacy_id = legacy
79
- symbol.id = stable
80
- symbol.identity_version = "v2"
81
89
 
82
90
  for relation in parsed.relations:
83
91
  relation.from_symbol_id = id_map.get(relation.from_symbol_id, relation.from_symbol_id)
@@ -8,6 +8,8 @@ signatures, docstrings, decorators, parameters, cyclomatic complexity, and CALLS
8
8
  from __future__ import annotations
9
9
 
10
10
  import ast
11
+ from collections.abc import Iterator, Sequence
12
+ from typing import NotRequired, TypedDict
11
13
 
12
14
  from .model import (
13
15
  CodeRelation,
@@ -47,13 +49,20 @@ def _cyclomatic(node: ast.AST) -> dict[str, int]:
47
49
  return {"cyclomatic": count, "lines": end - lineno + 1}
48
50
 
49
51
 
50
- def _params(args: ast.arguments) -> list[dict]:
51
- out: list[dict] = []
52
+ class _Parameter(TypedDict):
53
+ name: str
54
+ type: NotRequired[str]
55
+ is_variadic: NotRequired[bool]
56
+ is_kwonly: NotRequired[bool]
57
+
58
+
59
+ def _params(args: ast.arguments) -> list[_Parameter]:
60
+ out: list[_Parameter] = []
52
61
  posonly = getattr(args, "posonlyargs", [])
53
62
  for a in [*posonly, *args.args]:
54
63
  if a.arg in ("self", "cls"):
55
64
  continue
56
- p: dict = {"name": a.arg}
65
+ p: _Parameter = {"name": a.arg}
57
66
  if a.annotation is not None:
58
67
  p["type"] = ast.unparse(a.annotation)
59
68
  out.append(p)
@@ -109,7 +118,7 @@ def _callee_ref(call: ast.Call) -> SymbolReference | None:
109
118
  return None
110
119
 
111
120
 
112
- def _calls_owned_by(node: ast.AST):
121
+ def _calls_owned_by(node: ast.AST) -> Iterator[ast.Call]:
113
122
  """Yield calls owned by ``node`` without stealing calls from nested definitions."""
114
123
 
115
124
  stack = list(ast.iter_child_nodes(node))
@@ -122,13 +131,15 @@ def _calls_owned_by(node: ast.AST):
122
131
  stack.extend(ast.iter_child_nodes(child))
123
132
 
124
133
 
125
- def _bound_names(nodes: list[ast.AST]) -> set[str]:
126
- """Conservative lexical writes, including captures and deletion bindings."""
134
+ def _lexical_bindings(nodes: Sequence[ast.AST]) -> tuple[set[str], set[str]]:
135
+ """Collect writes and definition bindings without entering nested namespaces."""
127
136
  names: set[str] = set()
137
+ definitions: set[str] = set()
128
138
  stack = list(nodes)
129
139
  while stack:
130
140
  node = stack.pop()
131
141
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
142
+ definitions.add(node.name)
132
143
  continue
133
144
  if isinstance(node, ast.arg):
134
145
  names.add(node.arg)
@@ -141,7 +152,7 @@ def _bound_names(nodes: list[ast.AST]) -> set[str]:
141
152
  if isinstance(node, (ast.Import, ast.ImportFrom)):
142
153
  names.update(a.asname or a.name.split(".")[0] for a in node.names)
143
154
  stack.extend(ast.iter_child_nodes(node))
144
- return names
155
+ return names, definitions
145
156
 
146
157
 
147
158
  class _Visitor:
@@ -152,6 +163,7 @@ class _Visitor:
152
163
  self.relations: list[CodeRelation] = []
153
164
  self.imports: list[str] = []
154
165
  self._enclosing_bindings: list[set[str]] = []
166
+ self._definition_scopes: list[dict[str, list[str]]] = [{}]
155
167
  self._import_names: set[str] = set()
156
168
  self._wildcard_import = False
157
169
  self._fqn_prefix = file_path.replace("/", ".").replace("\\", ".")
@@ -190,7 +202,7 @@ class _Visitor:
190
202
  symbol_type: CodeSymbolType,
191
203
  scope: list[str],
192
204
  signature: str | None = None,
193
- params: list[dict] | None = None,
205
+ params: list[_Parameter] | None = None,
194
206
  return_type: str | None = None,
195
207
  modifiers: list[str] | None = None,
196
208
  ) -> CodeSymbol:
@@ -220,7 +232,7 @@ class _Visitor:
220
232
  signature=signature,
221
233
  modifiers=modifiers or [],
222
234
  scope_chain=list(scope),
223
- parameters=params or [],
235
+ parameters=[dict(parameter) for parameter in params or []],
224
236
  return_type=return_type,
225
237
  complexity=_cyclomatic(node),
226
238
  )
@@ -254,10 +266,16 @@ class _Visitor:
254
266
  bound.add(node.args.vararg.arg)
255
267
  if node.args.kwarg:
256
268
  bound.add(node.args.kwarg.arg)
257
- writes = _bound_names(node.body)
269
+ writes, definitions = _lexical_bindings(node.body)
270
+ inner = [*scope, name]
271
+ definition_scopes = {
272
+ **self._definition_scopes[-1],
273
+ **{definition: inner for definition in definitions},
274
+ }
258
275
  enclosing = set().union(*self._enclosing_bindings)
259
276
  sym.metadata["python_bound_names"] = sorted(bound | writes | enclosing)
260
277
  sym.metadata["python_rebound_names"] = sorted(writes | enclosing)
278
+ sym.metadata["python_definition_scopes"] = definition_scopes
261
279
  sym.metadata["python_import_names"] = sorted(self._import_names)
262
280
  sym.metadata["python_wildcard_import"] = self._wildcard_import
263
281
  sym.metadata["python_receiver"] = (
@@ -275,10 +293,10 @@ class _Visitor:
275
293
  # and otherwise keeps it as a bare name (so cross-file/external calls — e.g.
276
294
  # a CPG's blast radius — are not silently dropped). ``call_site`` records the
277
295
  # call line for consumers that need it.
278
- for child in _calls_owned_by(node):
279
- ref = _callee_ref(child)
296
+ for call in _calls_owned_by(node):
297
+ ref = _callee_ref(call)
280
298
  if ref:
281
- line, col, end_line, end_col, offset, length = self._span(child)
299
+ line, col, end_line, end_col, offset, length = self._span(call)
282
300
  self.relations.append(
283
301
  CodeRelation(
284
302
  from_symbol_id=sym.id,
@@ -297,8 +315,8 @@ class _Visitor:
297
315
  ),
298
316
  )
299
317
  )
300
- inner = [*scope, name]
301
318
  self._enclosing_bindings.append(bound | writes)
319
+ self._definition_scopes.append(definition_scopes)
302
320
  for child in node.body:
303
321
  nested: CodeSymbol | None = None
304
322
  if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
@@ -314,6 +332,7 @@ class _Visitor:
314
332
  )
315
333
  )
316
334
  self._enclosing_bindings.pop()
335
+ self._definition_scopes.pop()
317
336
  return sym
318
337
 
319
338
  def visit_class(self, node: ast.ClassDef, scope: list[str]) -> CodeSymbol:
@@ -322,6 +341,7 @@ class _Visitor:
322
341
  if bases:
323
342
  mods.append(f"extends({','.join(bases)})")
324
343
  cls = self._make_symbol(node, node.name, CodeSymbolType.CLASS, scope, modifiers=mods)
344
+ cls.metadata["python_definition_scopes"] = self._definition_scopes[-1]
325
345
  cls.metadata["python_rebound_names"] = sorted(set().union(*self._enclosing_bindings))
326
346
  self.symbols.append(cls)
327
347
  for base in bases:
@@ -356,9 +376,11 @@ class _Visitor:
356
376
  return cls
357
377
 
358
378
  def run(self, tree: ast.Module) -> None:
359
- self._enclosing_bindings = [
360
- _bound_names([n for n in tree.body if not isinstance(n, (ast.Import, ast.ImportFrom))])
361
- ]
379
+ module_writes, _ = _lexical_bindings(
380
+ [n for n in tree.body if not isinstance(n, (ast.Import, ast.ImportFrom))]
381
+ )
382
+ self._enclosing_bindings = [module_writes]
383
+ self._definition_scopes = [{}]
362
384
  self._import_names = set()
363
385
  for declaration in tree.body:
364
386
  if isinstance(declaration, (ast.Import, ast.ImportFrom)):
@@ -5,8 +5,12 @@ from __future__ import annotations
5
5
  import ast
6
6
  import hashlib
7
7
  import os
8
- from dataclasses import dataclass, field, replace
8
+ from dataclasses import asdict, dataclass, field, replace
9
9
  from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from .rust_repository import RustBinding
10
14
 
11
15
  from .model import (
12
16
  CodeRelation,
@@ -67,7 +71,7 @@ def _resolve_across_files(
67
71
  symbols: list[CodeSymbol],
68
72
  relations: list[CodeRelation],
69
73
  files: dict[str, ParsedCode],
70
- rust_bindings: dict[str, list] | None = None,
74
+ rust_bindings: dict[str, list[RustBinding]] | None = None,
71
75
  javascript: JavaScriptRepository | None = None,
72
76
  ) -> list[CodeRelation]:
73
77
  """Resolve only unambiguous repository targets; ambiguity remains explicit."""
@@ -147,11 +151,15 @@ def _resolve_across_files(
147
151
  if source.language == "python":
148
152
  text = ref.text or ref.name
149
153
  root, *tail = text.split(".")
150
- if not source.metadata.get(
151
- "python_wildcard_import"
152
- ) and root not in source.metadata.get("python_bound_names", []):
153
- for binding, targets in python_bindings.get(source.location.file_path, {}).items():
154
- prefix = binding.split(".")
154
+ if (
155
+ not source.metadata.get("python_wildcard_import")
156
+ and root not in source.metadata.get("python_bound_names", [])
157
+ and root not in source.metadata.get("python_definition_scopes", {})
158
+ ):
159
+ for import_name, targets in python_bindings.get(
160
+ source.location.file_path, {}
161
+ ).items():
162
+ prefix = import_name.split(".")
155
163
  parts = text.split(".")
156
164
  if parts[: len(prefix)] != prefix:
157
165
  continue
@@ -198,7 +206,7 @@ def _python_bindings(
198
206
  if isinstance(node, ast.ImportFrom):
199
207
  module = "." * node.level + (node.module or "")
200
208
  name = alias.asname or alias.name
201
- target = (alias.name,)
209
+ target: tuple[str, ...] = (alias.name,)
202
210
  options = [
203
211
  (module, target),
204
212
  (module + ("." if node.module else "") + alias.name, ()),
@@ -245,6 +253,40 @@ def _module_candidates(module: str, importer: str) -> tuple[str, ...]:
245
253
  return f"{stem}.py", f"{stem}/__init__.py"
246
254
 
247
255
 
256
+ def _file_projection_metadata(parsed: ParsedCode) -> dict[str, object]:
257
+ """Keep diagnostic evidence while quarantining ambiguous symbol identities."""
258
+ quarantined = any(item.code == "identity_collision" for item in parsed.diagnostics)
259
+ groups: dict[str, list[CodeSymbol]] = {}
260
+ if quarantined:
261
+ for symbol in parsed.symbols:
262
+ groups.setdefault(symbol.id, []).append(symbol)
263
+ return {
264
+ "parse_status": parsed.status.value,
265
+ "capability_tier": parsed.capability_tier.value,
266
+ "diagnostics": [asdict(item) for item in parsed.diagnostics],
267
+ "projection_status": "file_marker_only" if quarantined else "symbols",
268
+ "omitted_symbol_count": len(parsed.symbols) if quarantined else 0,
269
+ "omitted_relation_count": len(parsed.relations) if quarantined else 0,
270
+ "identity_collisions": [
271
+ {
272
+ "symbol_id": symbol_id,
273
+ "resolution": "ambiguous",
274
+ "declarations": [
275
+ {
276
+ "name": symbol.simple_name,
277
+ "qualified_name": symbol.fully_qualified_name,
278
+ "symbol_type": symbol.symbol_type.name.lower(),
279
+ "source_span": asdict(symbol.location),
280
+ }
281
+ for symbol in declarations
282
+ ],
283
+ }
284
+ for symbol_id, declarations in groups.items()
285
+ if len(declarations) > 1
286
+ ],
287
+ }
288
+
289
+
248
290
  def parse_repo(
249
291
  root: str | os.PathLike[str],
250
292
  *,
@@ -288,18 +330,33 @@ def parse_repo(
288
330
  ),
289
331
  source_code="",
290
332
  language=parsed.language,
291
- metadata={"embedding_excluded": True, "content_version": parsed.content_hash},
333
+ metadata={
334
+ "embedding_excluded": True,
335
+ "content_version": parsed.content_hash,
336
+ **_file_projection_metadata(parsed),
337
+ },
292
338
  )
293
339
  file_symbols[relative] = file_symbol
294
340
 
295
- symbols = [*file_symbols.values(), *(s for parsed in files.values() for s in parsed.symbols)]
296
- relations = [relation for parsed in files.values() for relation in parsed.relations]
341
+ # Retain raw ParsedCode in `files` for diagnostics, but never feed ambiguous
342
+ # declarations into symbol lookup or cross-file resolution. The canonical
343
+ # FILE node stays visible, including to healthy files importing this module.
344
+ resolution_files = {
345
+ path: parsed
346
+ for path, parsed in files.items()
347
+ if file_symbols[path].metadata["projection_status"] != "file_marker_only"
348
+ }
349
+ symbols = [
350
+ *file_symbols.values(),
351
+ *(symbol for parsed in resolution_files.values() for symbol in parsed.symbols),
352
+ ]
353
+ relations = [relation for parsed in resolution_files.values() for relation in parsed.relations]
297
354
  from .rust_repository import RustRepository, use_paths
298
355
 
299
- rust = RustRepository(root_path, files)
300
- javascript = JavaScriptRepository(files)
301
- rust_bindings: dict[str, list] = {}
302
- for relative, parsed in files.items():
356
+ rust = RustRepository(root_path, resolution_files)
357
+ javascript = JavaScriptRepository(resolution_files)
358
+ rust_bindings: dict[str, list[RustBinding]] = {}
359
+ for relative, parsed in resolution_files.items():
303
360
  owner = file_symbols[relative]
304
361
  for symbol in parsed.symbols:
305
362
  if not symbol.scope_chain:
@@ -379,7 +436,9 @@ def parse_repo(
379
436
  )
380
437
  # Re-run the scope resolver over the full stable table only for already textual
381
438
  # targets, then apply conservative unique-name cross-file linking.
382
- relations = _resolve_across_files(symbols, relations, files, rust_bindings, javascript)
439
+ relations = _resolve_across_files(
440
+ symbols, relations, resolution_files, rust_bindings, javascript
441
+ )
383
442
  generation_raw = "\n".join(f"{path}\x1f{digest}" for path, digest in sorted(manifest.items()))
384
443
  generation_id = hashlib.blake2b(generation_raw.encode(), digest_size=16).hexdigest()
385
444
  return ParsedRepository(
@@ -72,6 +72,7 @@ def resolve_relations(
72
72
  if source is not None and source.language == "rust":
73
73
  candidates = _rust_candidates(source, ref, candidates, symbols)
74
74
  elif source is not None and source.language == "python":
75
+ definition_scope = source.metadata.get("python_definition_scopes", {}).get(ref.name)
75
76
  if source.metadata.get("python_wildcard_import"):
76
77
  candidates = []
77
78
  elif ref.qualifier in {"self", "cls"}:
@@ -87,7 +88,10 @@ def resolve_relations(
87
88
  elif (
88
89
  ref.qualifier
89
90
  or ref.name in source.metadata.get("python_bound_names", [])
90
- or ref.name in source.metadata.get("python_import_names", [])
91
+ or (
92
+ ref.name in source.metadata.get("python_import_names", [])
93
+ and definition_scope is None
94
+ )
91
95
  ):
92
96
  candidates = []
93
97
  else:
@@ -104,6 +108,10 @@ def resolve_relations(
104
108
  and [*source.scope_chain, source.simple_name][: len(c.scope_chain)]
105
109
  == c.scope_chain
106
110
  ]
111
+ # A lexical definition shadows imports and outer definitions even
112
+ # when its conditional body is not represented by this parser.
113
+ if definition_scope is not None:
114
+ candidates = [c for c in candidates if c.scope_chain == definition_scope]
107
115
  nearest = max((len(c.scope_chain) for c in candidates), default=-1)
108
116
  candidates = [c for c in candidates if len(c.scope_chain) == nearest]
109
117
  elif source is not None and source.language in {"javascript", "typescript", "tsx"}:
@@ -426,7 +426,9 @@ def _go_receiver_type(node, src: bytes) -> str | None:
426
426
  def _walk_collect(node, src, file_path, language, frames, symbols, relations, impl_traits):
427
427
  for child in _children(node):
428
428
  t = child.type
429
- if t == "object" and language in {"javascript", "typescript", "tsx"}:
429
+ if t == "object" and child.is_named and language in {"javascript", "typescript", "tsx"}:
430
+ # TypeScript also uses an unnamed `object` keyword in type positions.
431
+ # Only named object expressions introduce a literal scope.
430
432
  # Anonymous object literals have a structural AST path, not a line ID.
431
433
  # Comments/whitespace do not participate; inserting a sibling object
432
434
  # can change the ordinal and requires a fresh graph generation.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: victor-codegraph
3
- Version: 0.10.0
3
+ Version: 0.10.2
4
4
  Summary: Repository-to-Code-Semantic-Graph compiler with stable identity, bounded chunks, incremental deltas, and ProximaRecord projection.
5
5
  Author-email: Vijaykumar Singh <vijay@anvaiops.com>
6
6
  License: Apache-2.0
@@ -56,7 +56,11 @@ gaps:
56
56
  ## Install
57
57
 
58
58
  Published on PyPI as [`victor-codegraph`](https://pypi.org/project/victor-codegraph/).
59
- This source tree is version **0.10.0**; consumers should use a compatible `<1.0` pin.
59
+ This source tree is version **0.10.2**; consumers should use a compatible `<1.0` pin.
60
+ It repairs nested Python definition/import shadowing: proven local definitions
61
+ retain their targets, while unmodeled conditional bindings stay unresolved.
62
+ Rebuild derived graphs when adopting the new parser; unchanged source hashes do
63
+ not invalidate old semantic results automatically. Publication is a separate gate.
60
64
 
61
65
  ```bash
62
66
  # from PyPI
@@ -128,10 +132,36 @@ records = to_proxima_records(repository, repo_graph_id="myrepo")
128
132
 
129
133
  ## Status
130
134
 
131
- `0.10.0` source version. Python (`ast`) is the full, offline frontend. JS/TS, Go, Rust,
135
+ `0.10.2` source version. Python (`ast`) is the full, offline frontend. JS/TS, Go, Rust,
132
136
  Java, C, and C++ have conformance fixtures; other mapped tree-sitter languages report
133
137
  symbols-tier or fallback status rather than implying full fidelity.
134
138
 
139
+ ### Ambiguous declarations
140
+
141
+ Duplicate structural identities retain raw declarations in `ParsedRepository.files`
142
+ and report each conflicting source location. Repository projection emits only the
143
+ canonical FILE marker for that file, with typed diagnostics, declaration spans and
144
+ omitted symbol/relation counts in metadata. Calls into its declarations remain
145
+ unresolved; healthy imports can still point to its diagnostic marker. Repairing the
146
+ source restores normal projection through deletion-complete repository deltas.
147
+ Direct record projection rejects duplicate identities before invoking an embedder,
148
+ including when legacy OIDs are selected.
149
+
150
+ `identity_collisions` groups use the existing `symbol_id` with `resolution: ambiguous`.
151
+ Each declaration retains `name`, `qualified_name`, `symbol_type` and its full
152
+ `source_span`; these are inspectable evidence, not separately resolved graph nodes.
153
+ The record adapter forwards these groups, projection status and omitted counts only
154
+ when present, without copying arbitrary symbol metadata into exported properties.
155
+
156
+ ### TypeScript grammar limitation
157
+
158
+ The current tree-sitter grammar reports a partial parse for inline import types
159
+ in generic calls, for example `load<typeof import('child_process')>()`, even though
160
+ TypeScript accepts that syntax. A named type alias (`type ChildProcessModule =
161
+ typeof import('child_process')`, then `load<ChildProcessModule>()`) is supported.
162
+ Partial status and syntax diagnostics remain visible; the parser does not rewrite
163
+ source or infer missing relationships.
164
+
135
165
  ### Rust graph fidelity
136
166
 
137
167
  Rust indexing retains inline module scopes and discriminates same-named trait
@@ -1,26 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from victor_codegraph import CapabilityTier, ChunkConfig, ParseStatus, parse
4
-
5
-
6
- def test_syntax_error_reports_fallback_diagnostic():
7
- parsed = parse("def broken(:\n", file_path="bad.py")
8
- assert parsed.status == ParseStatus.FALLBACK
9
- assert parsed.diagnostics
10
- assert parsed.diagnostics[0].code == "syntax_error"
11
-
12
-
13
- def test_unknown_language_reports_fallback_instead_of_silent_success():
14
- parsed = parse("plain text", language="unknown", file_path="x.unknown")
15
- assert parsed.status == ParseStatus.FALLBACK
16
- assert parsed.capability_tier == CapabilityTier.FALLBACK
17
- assert parsed.diagnostics
18
-
19
-
20
- def test_extract_relations_config_is_effective():
21
- parsed = parse(
22
- "def f():\n return g()\n",
23
- file_path="m.py",
24
- config=ChunkConfig(extract_relations=False),
25
- )
26
- assert parsed.relations == []
@@ -1,46 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import replace
4
-
5
- import pytest
6
-
7
- from victor_codegraph import apply_index_delta, diff_repository, parse_repo
8
-
9
-
10
- def test_repository_delta_deletes_removed_and_replaced_symbols(tmp_path):
11
- a = tmp_path / "a.py"
12
- b = tmp_path / "b.py"
13
- a.write_text("def old_name():\n return 1\n")
14
- b.write_text("def removed():\n return 2\n")
15
- previous = parse_repo(tmp_path, repo_id="repo")
16
-
17
- a.write_text("def new_name():\n return 1\n")
18
- b.unlink()
19
- current = parse_repo(tmp_path, repo_id="repo")
20
- delta = diff_repository(current, previous)
21
-
22
- old_function_ids = {s.id for s in previous.symbols if s.symbol_type.name == "FUNCTION"}
23
- assert old_function_ids <= set(delta.delete_symbol_ids)
24
- assert "new_name" in {s.simple_name for s in delta.upsert_symbols}
25
-
26
- rebuilt = apply_index_delta(previous, delta)
27
- assert {s.id for s in rebuilt.symbols} == {s.id for s in current.symbols}
28
- assert rebuilt.manifest == current.manifest
29
- assert delta.base_generation_id == previous.generation_id
30
- assert delta.target_generation_id == current.generation_id
31
-
32
- with pytest.raises(ValueError, match="base generation"):
33
- apply_index_delta(replace(previous, generation_id="stale"), delta)
34
-
35
-
36
- def test_repository_delta_refreshes_relation_evidence_on_line_shift(tmp_path):
37
- path = tmp_path / "m.py"
38
- path.write_text("def a():\n return b()\n\ndef b(): return 1\n")
39
- previous = parse_repo(tmp_path, repo_id="repo")
40
- path.write_text("\ndef a():\n return b()\n\ndef b(): return 1\n")
41
- current = parse_repo(tmp_path, repo_id="repo")
42
-
43
- delta = diff_repository(current, previous)
44
- assert delta.upsert_relations
45
- rebuilt = apply_index_delta(previous, delta)
46
- assert rebuilt.relations[0].call_site.start_line == current.relations[0].call_site.start_line