codegraph-engine 2.2.0__py3-none-any.whl → 2.3.0__py3-none-any.whl

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.
codegraph/git_state.py ADDED
@@ -0,0 +1,543 @@
1
+ """Git state tracking and freshness engine.
2
+
3
+ Tracks:
4
+ - current branch
5
+ - current HEAD commit SHA
6
+ - indexed HEAD commit SHA
7
+ - working-tree state (modified, staged, untracked relevant files, deleted)
8
+ - explicit freshness states: CLEAN, DIRTY, STALE, REINDEXING, ERROR
9
+
10
+ Enforces:
11
+ - sub-50ms quick freshness checks
12
+ - incremental synchronization of affected graph state without full re-indexing
13
+ - deterministic evidence-backed status reporting
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import sqlite3
18
+ import subprocess
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from enum import StrEnum
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from codegraph.errors import SecurityError
26
+ from codegraph.security import safe_path
27
+
28
+
29
+ class GitFreshnessState(StrEnum):
30
+ CLEAN = "CLEAN" # Current HEAD == indexed HEAD, working tree clean
31
+ DIRTY = "DIRTY" # Current HEAD == indexed HEAD, but uncommitted working tree changes
32
+ STALE = "STALE" # Current HEAD != indexed HEAD (commits advanced or branch switched)
33
+ REINDEXING = "REINDEXING" # Index lock active / reindexing underway
34
+ ERROR = "ERROR" # Not a git repo, git binary missing, or fatal error
35
+
36
+
37
+ @dataclass
38
+ class WorkingTreeStatus:
39
+ is_dirty: bool
40
+ modified_files: list[str] = field(default_factory=list)
41
+ staged_files: list[str] = field(default_factory=list)
42
+ untracked_files: list[str] = field(default_factory=list)
43
+ deleted_files: list[str] = field(default_factory=list)
44
+ renamed_files: list[dict[str, str]] = field(default_factory=list)
45
+
46
+ def as_dict(self) -> dict[str, Any]:
47
+ return {
48
+ "is_dirty": self.is_dirty,
49
+ "modified_files": self.modified_files,
50
+ "staged_files": self.staged_files,
51
+ "untracked_files": self.untracked_files,
52
+ "deleted_files": self.deleted_files,
53
+ "renamed_files": self.renamed_files,
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class GitStateReport:
59
+ is_git: bool
60
+ branch: str | None
61
+ current_head: str | None
62
+ indexed_head: str | None
63
+ freshness: GitFreshnessState
64
+ working_tree: WorkingTreeStatus
65
+ reindex_required: bool
66
+ detail: str = ""
67
+ last_indexed_at: int | None = None
68
+ affected_files_count: int = 0
69
+
70
+ def as_dict(self) -> dict[str, Any]:
71
+ return {
72
+ "is_git": self.is_git,
73
+ "branch": self.branch,
74
+ "current_head": self.current_head,
75
+ "indexed_head": self.indexed_head,
76
+ "freshness": self.freshness.value,
77
+ "working_tree": self.working_tree.as_dict(),
78
+ "reindex_required": self.reindex_required,
79
+ "detail": self.detail,
80
+ "last_indexed_at": self.last_indexed_at,
81
+ "affected_files_count": self.affected_files_count,
82
+ }
83
+
84
+
85
+ def _run_git_cmd(repository: Path, *args: str, timeout: int = 10) -> str | None:
86
+ """Execute an allowlisted git command safely within repository boundary."""
87
+ for arg in args:
88
+ if any(c in arg for c in (";", "|", "&", "`", "$", "\n", "\r")):
89
+ return None
90
+ try:
91
+ res = subprocess.run(
92
+ ["git", *args],
93
+ cwd=str(repository),
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=timeout,
97
+ )
98
+ return res.stdout if res.returncode == 0 else None
99
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
100
+ return None
101
+
102
+
103
+ def get_indexed_commit(con: sqlite3.Connection | None) -> str | None:
104
+ """Read indexed commit SHA from metadata table if available."""
105
+ if con is None:
106
+ return None
107
+ for table in ("metadata", "codegraph_meta"):
108
+ try:
109
+ row = con.execute(f"SELECT value FROM {table} WHERE key='indexed_commit'").fetchone()
110
+ if row and row[0]:
111
+ return str(row[0]).strip()
112
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
113
+ continue
114
+ return None
115
+
116
+
117
+ def get_last_indexed_timestamp(con: sqlite3.Connection | None) -> int | None:
118
+ """Read index_timestamp from metadata table if available."""
119
+ if con is None:
120
+ return None
121
+ for table in ("metadata", "codegraph_meta"):
122
+ try:
123
+ row = con.execute(f"SELECT value FROM {table} WHERE key='index_timestamp'").fetchone()
124
+ if row and row[0]:
125
+ return int(row[0])
126
+ except (sqlite3.OperationalError, sqlite3.DatabaseError, ValueError):
127
+ continue
128
+ return None
129
+
130
+
131
+ def is_reindexing_active(repository: Path) -> bool:
132
+ """Check if index lock or reindexing marker is currently active."""
133
+ lock_file = repository / ".codegraph" / "indexing.lock"
134
+ legacy_lock = repository / ".codegraph.lock"
135
+ return lock_file.exists() or legacy_lock.exists()
136
+
137
+
138
+ # Recognized source extensions for tracking relevant untracked files
139
+ RELEVANT_EXTENSIONS = {".py", ".ts", ".js", ".tsx", ".jsx", ".sql", ".prisma", ".json", ".toml", ".yaml", ".yml"}
140
+
141
+
142
+ def _fast_read_git_head_and_branch(repository: Path) -> tuple[str | None, str | None]:
143
+ """Pure-Python zero-subprocess resolution of HEAD commit SHA and current branch.
144
+
145
+ Resolves in <0.2ms on standard repositories, falling back to None if non-standard or missing.
146
+ """
147
+ git_dir = repository / ".git"
148
+ if git_dir.is_file():
149
+ # Git worktree or submodule pointing to another gitdir
150
+ try:
151
+ content = git_dir.read_text(encoding="utf-8", errors="replace").strip()
152
+ if content.startswith("gitdir:"):
153
+ target = Path(content.split(":", 1)[1].strip())
154
+ git_dir = target if target.is_absolute() else (repository / target).resolve()
155
+ except OSError:
156
+ return None, None
157
+ if not git_dir.exists() or not git_dir.is_dir():
158
+ return None, None
159
+
160
+ head_file = git_dir / "HEAD"
161
+ if not head_file.exists():
162
+ return None, None
163
+
164
+ try:
165
+ head_text = head_file.read_text(encoding="utf-8", errors="replace").strip()
166
+ if head_text.startswith("ref:"):
167
+ ref_path = head_text.split(":", 1)[1].strip()
168
+ branch = ref_path.replace("refs/heads/", "")
169
+ # Check loose ref
170
+ ref_file = git_dir / ref_path
171
+ if ref_file.exists():
172
+ return ref_file.read_text(encoding="utf-8", errors="replace").strip(), branch
173
+ # Check packed-refs
174
+ packed_file = git_dir / "packed-refs"
175
+ if packed_file.exists():
176
+ for line in packed_file.read_text(encoding="utf-8", errors="replace").splitlines():
177
+ if line.startswith("#") or line.startswith("^"):
178
+ continue
179
+ parts = line.strip().split()
180
+ if len(parts) == 2 and parts[1] == ref_path:
181
+ return parts[0], branch
182
+ return None, branch
183
+ elif len(head_text) == 40 and all(c in "0123456789abcdefABCDEF" for c in head_text):
184
+ return head_text, f"detached:{head_text[:8]}"
185
+ except OSError:
186
+ pass
187
+
188
+ return None, None
189
+
190
+
191
+ _WORKING_TREE_CACHE: dict[str, tuple[float, WorkingTreeStatus]] = {}
192
+
193
+
194
+ def clear_working_tree_cache() -> None:
195
+ """Clear cached working tree statuses."""
196
+ _WORKING_TREE_CACHE.clear()
197
+
198
+
199
+ def get_working_tree_status(repository: Path, max_age_ms: float = 0.0) -> WorkingTreeStatus:
200
+ """Inspect working tree status using git status porcelain v1 with optional sub-millisecond caching."""
201
+ repo_key = str(repository.resolve())
202
+ now = time.monotonic()
203
+ if max_age_ms > 0.0 and repo_key in _WORKING_TREE_CACHE:
204
+ cached_time, cached_val = _WORKING_TREE_CACHE[repo_key]
205
+ if (now - cached_time) * 1000.0 < max_age_ms:
206
+ return cached_val
207
+
208
+ raw = _run_git_cmd(repository, "status", "--porcelain=v1", "-uall")
209
+ if raw is None:
210
+ return WorkingTreeStatus(is_dirty=False)
211
+
212
+ modified: list[str] = []
213
+ staged: list[str] = []
214
+ untracked: list[str] = []
215
+ deleted: list[str] = []
216
+ renamed: list[dict[str, str]] = []
217
+
218
+ for line in raw.splitlines():
219
+ if len(line) < 4:
220
+ continue
221
+ index_status = line[0]
222
+ work_status = line[1]
223
+ path_part = line[3:].strip()
224
+
225
+ # Handle renamed format: "R old -> new"
226
+ if " -> " in path_part:
227
+ old_p, new_p = path_part.split(" -> ", 1)
228
+ old_p = old_p.strip().strip('"')
229
+ new_p = new_p.strip().strip('"')
230
+ renamed.append({"old_path": old_p, "new_path": new_p})
231
+ staged.append(new_p)
232
+ continue
233
+
234
+ clean_path = path_part.strip('"')
235
+
236
+ # Staged changes (index)
237
+ if index_status in ("M", "A", "R"):
238
+ if clean_path not in staged:
239
+ staged.append(clean_path)
240
+ elif index_status == "D":
241
+ if clean_path not in deleted:
242
+ deleted.append(clean_path)
243
+
244
+ # Working tree changes
245
+ if work_status == "M":
246
+ if clean_path not in modified:
247
+ modified.append(clean_path)
248
+ elif work_status == "D":
249
+ if clean_path not in deleted:
250
+ deleted.append(clean_path)
251
+ elif work_status == "?":
252
+ # Only include untracked if relevant source file
253
+ ext = Path(clean_path).suffix.lower()
254
+ if ext in RELEVANT_EXTENSIONS:
255
+ untracked.append(clean_path)
256
+
257
+ is_dirty = bool(modified or staged or untracked or deleted or renamed)
258
+ res = WorkingTreeStatus(
259
+ is_dirty=is_dirty,
260
+ modified_files=sorted(modified),
261
+ staged_files=sorted(staged),
262
+ untracked_files=sorted(untracked),
263
+ deleted_files=sorted(deleted),
264
+ renamed_files=renamed,
265
+ )
266
+ _WORKING_TREE_CACHE[repo_key] = (now, res)
267
+ return res
268
+
269
+
270
+ _DIFF_COUNT_CACHE: dict[tuple[str, str, str], int] = {}
271
+
272
+
273
+ def _get_cached_diff_count(repository: Path, base: str, head: str) -> int:
274
+ """Cache diff counts between immutable commit SHAs to avoid redundant subprocess invocations."""
275
+ cache_key = (str(repository), base, head)
276
+ if cache_key in _DIFF_COUNT_CACHE:
277
+ return _DIFF_COUNT_CACHE[cache_key]
278
+ diff_out = _run_git_cmd(repository, "diff", "--name-only", base, head)
279
+ count = len(diff_out.splitlines()) if diff_out else 0
280
+ if len(_DIFF_COUNT_CACHE) > 500:
281
+ _DIFF_COUNT_CACHE.clear()
282
+ _DIFF_COUNT_CACHE[cache_key] = count
283
+ return count
284
+
285
+
286
+ def get_git_state(
287
+ repository: Path,
288
+ con: sqlite3.Connection | None = None,
289
+ cache_ttl_ms: float = 0.0,
290
+ ) -> GitStateReport:
291
+ """Compute comprehensive Git state and freshness assessment."""
292
+ if is_reindexing_active(repository):
293
+ return GitStateReport(
294
+ is_git=True,
295
+ branch=None,
296
+ current_head=None,
297
+ indexed_head=get_indexed_commit(con),
298
+ freshness=GitFreshnessState.REINDEXING,
299
+ working_tree=WorkingTreeStatus(is_dirty=False),
300
+ reindex_required=False,
301
+ detail="Repository reindexing lock is currently active.",
302
+ last_indexed_at=get_last_indexed_timestamp(con),
303
+ )
304
+
305
+ # 1. Fast zero-subprocess check for Git directory, HEAD, and branch
306
+ current_head, branch = _fast_read_git_head_and_branch(repository)
307
+ is_git_confirmed = bool(current_head or branch or (repository / ".git").exists())
308
+
309
+ if not is_git_confirmed:
310
+ # Fallback check for non-standard GIT_DIR env or worktree
311
+ git_dir = _run_git_cmd(repository, "rev-parse", "--git-dir")
312
+ if git_dir is None:
313
+ return GitStateReport(
314
+ is_git=False,
315
+ branch=None,
316
+ current_head=None,
317
+ indexed_head=get_indexed_commit(con),
318
+ freshness=GitFreshnessState.ERROR,
319
+ working_tree=WorkingTreeStatus(is_dirty=False),
320
+ reindex_required=False,
321
+ detail="Repository is not a Git repository.",
322
+ last_indexed_at=get_last_indexed_timestamp(con),
323
+ )
324
+
325
+ # 2. If HEAD or branch was not resolved by fast-path, query Git CLI
326
+ if not current_head:
327
+ head_raw = _run_git_cmd(repository, "rev-parse", "HEAD")
328
+ current_head = head_raw.strip() if head_raw else None
329
+
330
+ if not branch:
331
+ branch_raw = _run_git_cmd(repository, "rev-parse", "--abbrev-ref", "HEAD")
332
+ branch = branch_raw.strip() if branch_raw else None
333
+ if branch == "HEAD":
334
+ branch = f"detached:{current_head[:8]}" if current_head else "detached"
335
+
336
+ indexed_head = get_indexed_commit(con)
337
+ last_indexed_at = get_last_indexed_timestamp(con)
338
+ wt_status = get_working_tree_status(repository, max_age_ms=cache_ttl_ms)
339
+
340
+ # 3. Determine freshness state
341
+ if indexed_head is None:
342
+ # Never indexed
343
+ freshness = GitFreshnessState.STALE
344
+ detail = "Repository has not been indexed yet."
345
+ reindex_required = True
346
+ affected_count = len(wt_status.modified_files) + len(wt_status.staged_files)
347
+ elif current_head != indexed_head:
348
+ # Commit advanced or branch switched
349
+ freshness = GitFreshnessState.STALE
350
+ detail = f"Indexed commit ({indexed_head[:8]}) does not match current HEAD ({current_head[:8] if current_head else 'none'})."
351
+ reindex_required = True
352
+ # Calculate diff files between indexed_head and current_head with caching
353
+ diff_count = _get_cached_diff_count(repository, indexed_head, current_head or "HEAD")
354
+ affected_count = diff_count + len(wt_status.modified_files) + len(wt_status.staged_files)
355
+ elif wt_status.is_dirty:
356
+ freshness = GitFreshnessState.DIRTY
357
+ detail = "Working tree has uncommitted modifications, staged changes, or relevant untracked files."
358
+ reindex_required = True
359
+ affected_count = len(wt_status.modified_files) + len(wt_status.staged_files) + len(wt_status.untracked_files) + len(wt_status.deleted_files)
360
+ else:
361
+ freshness = GitFreshnessState.CLEAN
362
+ detail = f"Index is fresh at HEAD ({current_head[:8] if current_head else 'clean'}) with clean working tree."
363
+ reindex_required = False
364
+ affected_count = 0
365
+
366
+ return GitStateReport(
367
+ is_git=True,
368
+ branch=branch,
369
+ current_head=current_head,
370
+ indexed_head=indexed_head,
371
+ freshness=freshness,
372
+ working_tree=wt_status,
373
+ reindex_required=reindex_required,
374
+ detail=detail,
375
+ last_indexed_at=last_indexed_at,
376
+ affected_files_count=affected_count,
377
+ )
378
+
379
+
380
+ @dataclass
381
+ class IncrementalSyncResult:
382
+ status: str
383
+ reindexed_files: list[str]
384
+ deleted_files: list[str]
385
+ new_commit: str | None
386
+ duration_ms: float
387
+ detail: str
388
+
389
+ def as_dict(self) -> dict[str, Any]:
390
+ return {
391
+ "status": self.status,
392
+ "reindexed_files": self.reindexed_files,
393
+ "deleted_files": self.deleted_files,
394
+ "new_commit": self.new_commit,
395
+ "duration_ms": self.duration_ms,
396
+ "detail": self.detail,
397
+ }
398
+
399
+
400
+ def incremental_git_sync(
401
+ repository: Path,
402
+ con: sqlite3.Connection,
403
+ ) -> IncrementalSyncResult:
404
+ """Incrementally synchronize the affected graph state without a full repository rebuild.
405
+
406
+ Identifies changed/added/deleted/renamed files and updates the index database
407
+ selectively, updating indexed_commit when working tree is clean.
408
+ """
409
+ import time
410
+
411
+ from codegraph.config import Settings
412
+ from codegraph.indexing import Indexer
413
+
414
+ start_time = time.perf_counter()
415
+ state = get_git_state(repository, con=con)
416
+
417
+ if not state.is_git:
418
+ return IncrementalSyncResult(
419
+ status="error",
420
+ reindexed_files=[],
421
+ deleted_files=[],
422
+ new_commit=None,
423
+ duration_ms=0.0,
424
+ detail="Cannot incrementally sync non-git repository.",
425
+ )
426
+
427
+ if state.freshness == GitFreshnessState.CLEAN:
428
+ return IncrementalSyncResult(
429
+ status="clean",
430
+ reindexed_files=[],
431
+ deleted_files=[],
432
+ new_commit=state.current_head,
433
+ duration_ms=(time.perf_counter() - start_time) * 1000.0,
434
+ detail="Repository index is already fresh.",
435
+ )
436
+
437
+ affected_modified: set[str] = set()
438
+ affected_deleted: set[str] = set()
439
+
440
+ # 1. Collect files changed between indexed_head and current_head
441
+ if state.indexed_head and state.current_head and state.indexed_head != state.current_head:
442
+ diff_raw = _run_git_cmd(repository, "diff", "--name-status", state.indexed_head, state.current_head)
443
+ if diff_raw:
444
+ for line in diff_raw.splitlines():
445
+ parts = line.strip().split(maxsplit=1)
446
+ if len(parts) == 2:
447
+ action, path_str = parts[0], parts[1]
448
+ if action.startswith("D"):
449
+ affected_deleted.add(path_str)
450
+ else:
451
+ affected_modified.add(path_str)
452
+
453
+ # 2. Add working tree changes
454
+ wt = state.working_tree
455
+ for p in wt.modified_files:
456
+ affected_modified.add(p)
457
+ for p in wt.staged_files:
458
+ affected_modified.add(p)
459
+ for p in wt.untracked_files:
460
+ affected_modified.add(p)
461
+ for p in wt.deleted_files:
462
+ affected_deleted.add(p)
463
+ for r in wt.renamed_files:
464
+ affected_deleted.add(r["old_path"])
465
+ affected_modified.add(r["new_path"])
466
+
467
+ # Don't try to index files that were deleted
468
+ affected_modified -= affected_deleted
469
+
470
+ if not affected_modified and not affected_deleted:
471
+ if not wt.is_dirty and state.current_head:
472
+ from codegraph.freshness import save_commit
473
+ save_commit(con, state.current_head, int(time.time()))
474
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
475
+ return IncrementalSyncResult(
476
+ status="clean",
477
+ reindexed_files=[],
478
+ deleted_files=[],
479
+ new_commit=state.current_head,
480
+ duration_ms=elapsed_ms,
481
+ detail="Repository index is already fresh.",
482
+ )
483
+
484
+ # Query known hashes to skip unchanged files
485
+ existing_hashes: dict[str, str] = {}
486
+ try:
487
+ cur = con.execute("SELECT path, hash FROM files")
488
+ for row in cur.fetchall():
489
+ existing_hashes[str(row[0])] = str(row[1])
490
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
491
+ pass
492
+
493
+ from codegraph.indexing.scanner import LANGUAGES
494
+
495
+ indexer = Indexer(repository, Settings(repository=repository))
496
+ reindexed: list[str] = []
497
+ removed: list[str] = []
498
+
499
+ # 3. Clean up deleted files from graph
500
+ for d_path in affected_deleted:
501
+ indexer._delete_file(con, d_path)
502
+ removed.append(d_path)
503
+
504
+ # 4. Incrementally index modified / added files
505
+ import hashlib
506
+ for m_path in affected_modified:
507
+ abs_p = repository / m_path
508
+ if abs_p.exists() and abs_p.is_file():
509
+ try:
510
+ # safe_path check
511
+ safe_path(repository, m_path)
512
+ ext = abs_p.suffix.lower()
513
+ lang = LANGUAGES.get(ext)
514
+ if not lang:
515
+ continue
516
+ content = abs_p.read_text(encoding="utf-8", errors="replace")
517
+ digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
518
+ if existing_hashes.get(m_path) == digest:
519
+ # Content is unchanged from current index; skip reindexing
520
+ continue
521
+ indexer._replace_file(con, m_path, lang, content, digest)
522
+ reindexed.append(m_path)
523
+ except (SecurityError, Exception):
524
+ continue
525
+
526
+ if reindexed or removed:
527
+ con.commit()
528
+
529
+ # 5. If working tree is clean, update indexed_commit
530
+ if not wt.is_dirty and state.current_head:
531
+ from codegraph.freshness import save_commit
532
+ save_commit(con, state.current_head, int(time.time()))
533
+
534
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
535
+
536
+ return IncrementalSyncResult(
537
+ status="synced",
538
+ reindexed_files=sorted(reindexed),
539
+ deleted_files=sorted(removed),
540
+ new_commit=state.current_head,
541
+ duration_ms=elapsed_ms,
542
+ detail=f"Incrementally updated {len(reindexed)} files and removed {len(removed)} deleted files in {elapsed_ms:.1f}ms.",
543
+ )
@@ -597,29 +597,63 @@ class _PythonScopeVisitor(ast.NodeVisitor):
597
597
  doc = ast.get_docstring(node)
598
598
  c_hash = _normalized_body_hash(self.lines, node.lineno, end_line)
599
599
 
600
- self.symbols.append(
601
- Symbol(
602
- id=canon_id,
603
- canonical_id=canon_id,
604
- name=node.name,
605
- qualified_name=qname,
606
- kind=kind,
607
- language="python",
608
- module=self.module,
609
- path=self.file_path,
610
- file_path=self.file_path,
611
- scope=parent_scope,
612
- signature=sig,
613
- start_line=node.lineno,
614
- end_line=end_line,
600
+ # Avoid duplicate canonical symbol IDs for @name.setter within the same class
601
+ is_setter = any(d.endswith(".setter") for d in decorators)
602
+ existing_prop = None
603
+ if is_setter and parent_kind == "class":
604
+ for s in self.symbols:
605
+ if s.canonical_id == canon_id and s.kind == "property":
606
+ existing_prop = s
607
+ break
608
+
609
+ if existing_prop is not None:
610
+ # Merge setter signature/span into the existing property symbol
611
+ idx = self.symbols.index(existing_prop)
612
+ self.symbols[idx] = Symbol(
613
+ id=existing_prop.id,
614
+ canonical_id=existing_prop.canonical_id,
615
+ name=existing_prop.name,
616
+ qualified_name=existing_prop.qualified_name,
617
+ kind="property",
618
+ language=existing_prop.language,
619
+ module=existing_prop.module,
620
+ path=existing_prop.path,
621
+ file_path=existing_prop.file_path,
622
+ scope=existing_prop.scope,
623
+ signature=f"{existing_prop.signature} | setter: {sig}" if existing_prop.signature else sig,
624
+ start_line=existing_prop.start_line,
625
+ end_line=max(existing_prop.end_line, end_line),
615
626
  content_hash=c_hash,
616
- parent_symbol_id=parent_canon,
617
- decorators=decorators,
618
- return_type=ret_type,
619
- parameter_count=param_count,
620
- documentation=doc,
627
+ parent_symbol_id=existing_prop.parent_symbol_id,
628
+ decorators=list(dict.fromkeys(existing_prop.decorators + decorators)),
629
+ return_type=existing_prop.return_type,
630
+ parameter_count=existing_prop.parameter_count,
631
+ documentation=existing_prop.documentation or doc,
632
+ )
633
+ else:
634
+ self.symbols.append(
635
+ Symbol(
636
+ id=canon_id,
637
+ canonical_id=canon_id,
638
+ name=node.name,
639
+ qualified_name=qname,
640
+ kind=kind,
641
+ language="python",
642
+ module=self.module,
643
+ path=self.file_path,
644
+ file_path=self.file_path,
645
+ scope=parent_scope,
646
+ signature=sig,
647
+ start_line=node.lineno,
648
+ end_line=end_line,
649
+ content_hash=c_hash,
650
+ parent_symbol_id=parent_canon,
651
+ decorators=decorators,
652
+ return_type=ret_type,
653
+ parameter_count=param_count,
654
+ documentation=doc,
655
+ )
621
656
  )
622
- )
623
657
 
624
658
  # Framework analyzer hook: check function/class for route decorators during this traversal
625
659
  if node.decorator_list or isinstance(node, ast.ClassDef):