codegraph-ir 0.1.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.
Files changed (63) hide show
  1. cgir/__init__.py +6 -0
  2. cgir/analyses/__init__.py +7 -0
  3. cgir/analyses/call_graph.py +114 -0
  4. cgir/analyses/cfg.py +320 -0
  5. cgir/analyses/effects.py +95 -0
  6. cgir/analyses/entrypoints.py +55 -0
  7. cgir/analyses/param_flow.py +75 -0
  8. cgir/analyses/pdg.py +75 -0
  9. cgir/analyses/purity.py +37 -0
  10. cgir/analyses/reaching_defs.py +115 -0
  11. cgir/analyses/symbols.py +106 -0
  12. cgir/api/__init__.py +1 -0
  13. cgir/api/mcp_server.py +172 -0
  14. cgir/api/server.py +107 -0
  15. cgir/cli.py +688 -0
  16. cgir/config.py +22 -0
  17. cgir/export/__init__.py +5 -0
  18. cgir/export/graphml.py +52 -0
  19. cgir/export/html_viz.py +1087 -0
  20. cgir/export/json_export.py +37 -0
  21. cgir/export/mermaid.py +57 -0
  22. cgir/export/neo4j.py +11 -0
  23. cgir/hooks.py +189 -0
  24. cgir/ir/__init__.py +16 -0
  25. cgir/ir/component_spec.py +100 -0
  26. cgir/ir/edges.py +31 -0
  27. cgir/ir/graph.py +132 -0
  28. cgir/ir/nodes.py +38 -0
  29. cgir/languages/__init__.py +25 -0
  30. cgir/languages/base.py +221 -0
  31. cgir/languages/cache.py +73 -0
  32. cgir/languages/python.py +1145 -0
  33. cgir/languages/registry.py +24 -0
  34. cgir/languages/typescript.py +853 -0
  35. cgir/manifest.py +77 -0
  36. cgir/pipeline.py +54 -0
  37. cgir/py.typed +0 -0
  38. cgir/regenerate/__init__.py +6 -0
  39. cgir/regenerate/prompt_pack.py +16 -0
  40. cgir/regenerate/regenerator.py +108 -0
  41. cgir/report/__init__.py +5 -0
  42. cgir/report/diff.py +226 -0
  43. cgir/report/flow.py +84 -0
  44. cgir/report/impact.py +234 -0
  45. cgir/report/lint.py +84 -0
  46. cgir/report/pack.py +271 -0
  47. cgir/report/stats.py +121 -0
  48. cgir/slicing/__init__.py +5 -0
  49. cgir/slicing/slicer.py +174 -0
  50. cgir/sources/__init__.py +6 -0
  51. cgir/sources/base.py +14 -0
  52. cgir/sources/codeql_source.py +13 -0
  53. cgir/sources/joern_source.py +13 -0
  54. cgir/sources/tree_sitter_source.py +270 -0
  55. cgir/trace/__init__.py +5 -0
  56. cgir/trace/trace_map.py +83 -0
  57. cgir/verify.py +173 -0
  58. cgir/watch.py +171 -0
  59. codegraph_ir-0.1.0.dist-info/METADATA +143 -0
  60. codegraph_ir-0.1.0.dist-info/RECORD +63 -0
  61. codegraph_ir-0.1.0.dist-info/WHEEL +4 -0
  62. codegraph_ir-0.1.0.dist-info/entry_points.txt +2 -0
  63. codegraph_ir-0.1.0.dist-info/licenses/LICENSE +21 -0
cgir/report/stats.py ADDED
@@ -0,0 +1,121 @@
1
+ """Codebase structure report over ComponentSpecs.
2
+
3
+ ``compute_stats`` is a pure function from ``list[ComponentSpec]`` to a
4
+ JSON-able dict — it drives both the ``cgir stats`` text output and its
5
+ ``--json`` mode. Purity buckets follow the scoring rubric in
6
+ :mod:`cgir.analyses.purity`: 1.0 pure, 0.7 effect-tainted (calls into
7
+ effectful code only), everything below direct-impure.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import Counter
13
+ from typing import Any
14
+
15
+ from cgir.analyses.effects import IMPURE_EFFECT_TAGS
16
+ from cgir.ir.component_spec import ComponentSpec
17
+
18
+ TOP_N = 10
19
+ _IMPURE = set(IMPURE_EFFECT_TAGS)
20
+
21
+
22
+ def compute_stats(specs: list[ComponentSpec], top_n: int = TOP_N) -> dict[str, Any]:
23
+ known_ids = {s.id for s in specs}
24
+ files = {s.trace[0].rsplit(":", 1)[0] for s in specs if s.trace}
25
+
26
+ kinds = Counter(s.kind.value for s in specs)
27
+ effects = Counter(tag for s in specs for tag in s.effects)
28
+
29
+ internal_callers: Counter[str] = Counter()
30
+ external_callers: Counter[str] = Counter()
31
+ constructed: Counter[str] = Counter()
32
+ for s in specs:
33
+ for callee in set(s.calls):
34
+ if callee in known_ids:
35
+ internal_callers[callee] += 1
36
+ else:
37
+ external_callers[callee] += 1
38
+ for type_name in set(s.constructs):
39
+ constructed[type_name] += 1
40
+
41
+ pure = sum(1 for s in specs if s.purity == 1.0)
42
+ tainted = sum(1 for s in specs if s.purity == 0.7)
43
+ impure = len(specs) - pure - tainted
44
+ mean = sum(s.purity or 0.0 for s in specs) / len(specs) if specs else 0.0
45
+
46
+ fan_out = sorted(specs, key=lambda s: (-len(s.calls), s.id))
47
+
48
+ return {
49
+ "total": len(specs),
50
+ "files": len(files),
51
+ "kinds": dict(kinds),
52
+ "purity": {"mean": mean, "pure": pure, "tainted": tainted, "impure": impure},
53
+ "effects": dict(effects),
54
+ "most_called": [
55
+ {"id": callee, "callers": n} for callee, n in internal_callers.most_common(top_n)
56
+ ],
57
+ "top_fan_out": [{"id": s.id, "calls": len(s.calls)} for s in fan_out[:top_n] if s.calls],
58
+ "external_calls": [
59
+ {"id": callee, "callers": n} for callee, n in external_callers.most_common(top_n)
60
+ ],
61
+ "top_constructed": [
62
+ {"id": type_name, "constructors": n} for type_name, n in constructed.most_common(top_n)
63
+ ],
64
+ "entrypoints": [
65
+ {"id": s.id, "entrypoint": s.entrypoint}
66
+ for s in sorted(specs, key=lambda s: (s.entrypoint or "", s.id))
67
+ if s.entrypoint
68
+ ],
69
+ "untested_effectful": [
70
+ {"id": s.id, "effects": s.effects}
71
+ for s in sorted(specs, key=lambda s: s.id)
72
+ if _IMPURE & set(s.effects) and not s.covered_by
73
+ ],
74
+ }
75
+
76
+
77
+ def render_text(stats: dict[str, Any]) -> str:
78
+ """Terminal-friendly rendering of :func:`compute_stats` output."""
79
+ lines: list[str] = []
80
+ lines.append(f"Components: {stats['total']} (files: {stats['files']})")
81
+
82
+ if stats["kinds"]:
83
+ kinds = " · ".join(f"{k} {n}" for k, n in sorted(stats["kinds"].items()))
84
+ lines.append(f"Kinds: {kinds}")
85
+
86
+ purity = stats["purity"]
87
+ lines.append(
88
+ f"Purity: mean {purity['mean']:.2f} · pure {purity['pure']}"
89
+ f" · tainted {purity['tainted']} · impure {purity['impure']}"
90
+ )
91
+
92
+ if stats["effects"]:
93
+ effects = " · ".join(f"{k} {n}" for k, n in sorted(stats["effects"].items()))
94
+ lines.append(f"Effects: {effects}")
95
+
96
+ if stats["entrypoints"]:
97
+ lines.append("Entrypoints:")
98
+ width = max(len(e["entrypoint"]) for e in stats["entrypoints"])
99
+ for entry in stats["entrypoints"]:
100
+ lines.append(f" {entry['entrypoint']:<{width}} {entry['id']}")
101
+
102
+ if stats["untested_effectful"]:
103
+ lines.append(f"Untested effectful ({len(stats['untested_effectful'])}):")
104
+ for entry in stats["untested_effectful"][:TOP_N]:
105
+ lines.append(f" [{','.join(entry['effects'])}] {entry['id']}")
106
+
107
+ for title, key, count_key in (
108
+ ("Most called", "most_called", "callers"),
109
+ ("Top fan-out", "top_fan_out", "calls"),
110
+ ("Constructed types", "top_constructed", "constructors"),
111
+ ("External calls", "external_calls", "callers"),
112
+ ):
113
+ entries = stats[key]
114
+ if not entries:
115
+ continue
116
+ lines.append(f"{title}:")
117
+ width = len(str(entries[0][count_key]))
118
+ for entry in entries:
119
+ lines.append(f" {entry[count_key]:>{width}}x {entry['id']}")
120
+
121
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,5 @@
1
+ """Component slicing: RepoGraph → ComponentSpec[]."""
2
+
3
+ from cgir.slicing.slicer import slice_components
4
+
5
+ __all__ = ["slice_components"]
cgir/slicing/slicer.py ADDED
@@ -0,0 +1,174 @@
1
+ """Slice each Function/Method node into a ComponentSpec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from cgir.analyses.effects import IMPURE_EFFECT_TAGS, TRANSITIVE_TAG
8
+ from cgir.analyses.entrypoints import detect as detect_entrypoint
9
+ from cgir.analyses.purity import PLACEHOLDER_SCORE
10
+ from cgir.ir.component_spec import ComponentKind, ComponentSpec
11
+ from cgir.ir.edges import EdgeKind
12
+ from cgir.ir.graph import RepoGraph
13
+ from cgir.ir.nodes import Node, NodeKind
14
+ from cgir.languages import adapter_for_extension
15
+
16
+
17
+ def _node_language(node: Node, fallback: str) -> str:
18
+ """The component's language, from its file's adapter (multi-language repos)."""
19
+ if node.path:
20
+ adapter = adapter_for_extension(Path(node.path).suffix)
21
+ if adapter is not None:
22
+ return adapter.name
23
+ return fallback
24
+
25
+
26
+ def slice_components(
27
+ graph: RepoGraph,
28
+ effects: dict[str, list[str]] | None = None,
29
+ purity_scores: dict[str, float] | None = None,
30
+ language: str = "python",
31
+ ) -> list[ComponentSpec]:
32
+ effects = effects or {}
33
+ purity_scores = purity_scores or {}
34
+ specs: list[ComponentSpec] = []
35
+
36
+ for node in graph.nodes():
37
+ if node.kind not in {NodeKind.Function, NodeKind.Method}:
38
+ continue
39
+
40
+ qual = str(node.attrs.get("qualname") or node.name)
41
+ inputs = [param.name for param in graph.children(node.id, NodeKind.Parameter)]
42
+ calls, constructs = _split_callees(graph, node.id)
43
+ node_effects = effects.get(node.id, [])
44
+ purity_score = purity_scores.get(node.id, PLACEHOLDER_SCORE)
45
+ mutates_state = _has_mutations(graph, node.id)
46
+ kind = _classify(node_effects, purity_score, mutates_state)
47
+
48
+ trace = []
49
+ if node.path is not None and node.start_line is not None:
50
+ trace.append(f"{node.path}:{node.start_line}")
51
+
52
+ attrs = node.attrs or {}
53
+ signature = attrs.get("signature")
54
+ returns = attrs.get("returns")
55
+ decorators = attrs.get("decorators")
56
+ doc = attrs.get("doc")
57
+ raises = attrs.get("raises")
58
+ covered_by = _covering_tests(graph, node.id)
59
+
60
+ specs.append(
61
+ ComponentSpec(
62
+ id=qual,
63
+ kind=kind,
64
+ inputs=inputs,
65
+ outputs=[returns] if isinstance(returns, str) and returns else [],
66
+ effects=list(node_effects),
67
+ calls=calls,
68
+ constructs=constructs,
69
+ trace=trace,
70
+ language=_node_language(node, language),
71
+ signature=signature if isinstance(signature, str) else None,
72
+ entrypoint=detect_entrypoint(
73
+ decorators if isinstance(decorators, list) else [], node.name
74
+ ),
75
+ doc=doc if isinstance(doc, str) and doc else None,
76
+ raises=list(raises) if isinstance(raises, list) else [],
77
+ covered_by=covered_by,
78
+ purity=purity_score,
79
+ )
80
+ )
81
+
82
+ specs.sort(key=lambda s: s.id)
83
+ return specs
84
+
85
+
86
+ def _covering_tests(graph: RepoGraph, func_id: str) -> list[str]:
87
+ """Test components that call this one (via resolved CALLS edges)."""
88
+ tests: set[str] = set()
89
+ for edge in graph.in_edges(func_id, EdgeKind.CALLS):
90
+ caller = graph.get_node(edge.src)
91
+ if _is_test_node(caller):
92
+ tests.add(_callee_label(graph, caller.id))
93
+ return sorted(tests)
94
+
95
+
96
+ def _is_test_node(node: Node) -> bool:
97
+ """Heuristic: pytest-style test functions in test files."""
98
+ if node.name.startswith("test_"):
99
+ return True
100
+ if node.path:
101
+ parts = node.path.replace("\\", "/").split("/")
102
+ stem = parts[-1].removesuffix(".py")
103
+ return "tests" in parts[:-1] or stem.startswith("test_") or stem.endswith("_test")
104
+ return False
105
+
106
+
107
+ def _split_callees(graph: RepoGraph, func_id: str) -> tuple[list[str], list[str]]:
108
+ """Partition CALLS edges into (calls, constructs).
109
+
110
+ A call whose target is a Class node is a construction: it resolves to
111
+ the class's ``__init__`` component when one is defined, else the class
112
+ qualname lands in ``constructs`` (dataclass / ORM style).
113
+ """
114
+ calls: set[str] = set()
115
+ constructs: set[str] = set()
116
+ for edge in graph.out_edges(func_id, EdgeKind.CALLS):
117
+ callee = graph.get_node(edge.dst)
118
+ if callee.kind == NodeKind.Class:
119
+ init = next(
120
+ (m for m in graph.children(callee.id, NodeKind.Method) if m.name == "__init__"),
121
+ None,
122
+ )
123
+ if init is not None:
124
+ calls.add(_callee_label(graph, init.id))
125
+ else:
126
+ constructs.add(_callee_label(graph, callee.id))
127
+ else:
128
+ calls.add(_callee_label(graph, edge.dst))
129
+ return sorted(calls), sorted(constructs)
130
+
131
+
132
+ def _callee_label(graph: RepoGraph, node_id: str) -> str:
133
+ node = graph.get_node(node_id)
134
+ qual = node.attrs.get("qualname") if node.attrs else None
135
+ return str(qual) if isinstance(qual, str) else node.name
136
+
137
+
138
+ def _has_mutations(graph: RepoGraph, func_id: str) -> bool:
139
+ """True if any CFG child mutates state *observable by the caller*.
140
+
141
+ Covers attribute/subscript assignment LHS (``self.x = v``, ``xs[0] = v``,
142
+ ``self.total += n``) on ``Assignment`` nodes and bare mutator method
143
+ calls (``xs.append(x)``) on ``Statement`` nodes — both populated by
144
+ :mod:`cgir.analyses.cfg` via ``attrs["mutates"]``.
145
+
146
+ Mutating an object the function *itself created* (a local) is invisible
147
+ to callers and stays pure: a mutated base name only counts if it is a
148
+ parameter, ``self``, or a name never written locally (a global).
149
+ """
150
+ params = {p.name for p in graph.children(func_id, NodeKind.Parameter)}
151
+ local_writes: set[str] = set()
152
+ mutated: list[str] = []
153
+ for child in graph.children(func_id):
154
+ attrs = child.attrs or {}
155
+ writes = attrs.get("writes")
156
+ if isinstance(writes, list):
157
+ local_writes.update(writes)
158
+ mutates = attrs.get("mutates")
159
+ if isinstance(mutates, list):
160
+ mutated.extend(mutates)
161
+ return any(base in params or base not in local_writes for base in mutated)
162
+
163
+
164
+ def _classify(effects: list[str], purity_score: float, mutates_state: bool) -> ComponentKind:
165
+ tags = set(effects)
166
+ if tags & IMPURE_EFFECT_TAGS:
167
+ return ComponentKind.effect_adapter
168
+ if TRANSITIVE_TAG in tags:
169
+ return ComponentKind.orchestrator
170
+ if mutates_state:
171
+ return ComponentKind.state_transformer
172
+ if purity_score == 1.0:
173
+ return ComponentKind.pure_function
174
+ return ComponentKind.unknown
@@ -0,0 +1,6 @@
1
+ """GraphSource adapters: tree-sitter (working) + joern/codeql stubs."""
2
+
3
+ from cgir.sources.base import GraphSource
4
+ from cgir.sources.tree_sitter_source import TreeSitterSource
5
+
6
+ __all__ = ["GraphSource", "TreeSitterSource"]
cgir/sources/base.py ADDED
@@ -0,0 +1,14 @@
1
+ """Abstract GraphSource — every backend normalizes into the same RepoGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+
8
+ from cgir.ir.graph import RepoGraph
9
+
10
+
11
+ class GraphSource(ABC):
12
+ @abstractmethod
13
+ def ingest(self, repo_path: Path) -> RepoGraph:
14
+ """Walk ``repo_path`` and produce a normalized RepoGraph."""
@@ -0,0 +1,13 @@
1
+ """CodeQL adapter — milestone: P2-codeql-bridge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from cgir.ir.graph import RepoGraph
8
+ from cgir.sources.base import GraphSource
9
+
10
+
11
+ class CodeQLSource(GraphSource):
12
+ def ingest(self, repo_path: Path) -> RepoGraph:
13
+ raise NotImplementedError("milestone: P2-codeql-bridge")
@@ -0,0 +1,13 @@
1
+ """Joern adapter — milestone: P2-joern-bridge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from cgir.ir.graph import RepoGraph
8
+ from cgir.sources.base import GraphSource
9
+
10
+
11
+ class JoernSource(GraphSource):
12
+ def ingest(self, repo_path: Path) -> RepoGraph:
13
+ raise NotImplementedError("milestone: P2-joern-bridge")
@@ -0,0 +1,270 @@
1
+ """Generic tree-sitter ingester — language-neutral over a LanguageAdapter.
2
+
3
+ Walks a repo, parses each source file with the active adapter, and emits
4
+ the ``Repository → File → Module → (Class → Method | Function)``
5
+ containment spine plus ``Parameter`` children, per-module ``Import``
6
+ siblings, and module-level ``Variable`` nodes.
7
+
8
+ All grammar-specific extraction (what a function/class/import looks like,
9
+ signatures, docstrings, raised names, free names, relative-import
10
+ resolution) comes from :meth:`LanguageAdapter.module_declarations` as
11
+ normalized declarations; this module only builds graph nodes and edges.
12
+ Symbol resolution and ``CALLS`` edges live in :mod:`cgir.analyses`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Iterable
18
+ from pathlib import Path
19
+
20
+ from tree_sitter import Node as TSNode
21
+
22
+ from cgir.ir.edges import Edge, EdgeKind
23
+ from cgir.ir.graph import RepoGraph
24
+ from cgir.ir.nodes import Node, NodeKind
25
+ from cgir.languages import ADAPTERS, LanguageAdapter, adapter_for_extension
26
+ from cgir.languages.base import ClassDecl, FunctionDecl, ImportDecl, VariableDecl
27
+ from cgir.languages.cache import parse_cached
28
+ from cgir.sources.base import GraphSource
29
+
30
+ DEFAULT_IGNORE_DIRS: frozenset[str] = frozenset(
31
+ {
32
+ # Virtual environments
33
+ "venv",
34
+ "env",
35
+ # Build / dist artefacts
36
+ "build",
37
+ "dist",
38
+ "target",
39
+ "out",
40
+ # Python caches
41
+ "__pycache__",
42
+ "site-packages",
43
+ # Tool caches (dot-prefixed names are also covered by the dot-prefix
44
+ # filter below; listed here for completeness when authors rename them)
45
+ ".tox",
46
+ ".pytest_cache",
47
+ ".mypy_cache",
48
+ ".ruff_cache",
49
+ # Other ecosystems sometimes vendored into repos
50
+ "node_modules",
51
+ }
52
+ )
53
+
54
+
55
+ class TreeSitterSource(GraphSource):
56
+ def __init__(
57
+ self,
58
+ ignore_dirs: Iterable[str] | None = None,
59
+ adapter: LanguageAdapter | None = None,
60
+ ) -> None:
61
+ # adapter=None → dispatch per file extension across all registered
62
+ # languages; a forced adapter restricts ingest to its extensions.
63
+ self._forced = adapter
64
+ self._ignore: frozenset[str] = DEFAULT_IGNORE_DIRS | frozenset(ignore_dirs or ())
65
+
66
+ def _extensions(self) -> tuple[str, ...]:
67
+ if self._forced is not None:
68
+ return self._forced.file_extensions
69
+ return tuple(ext for a in ADAPTERS.values() for ext in a.file_extensions)
70
+
71
+ def ingest(self, repo_path: Path) -> RepoGraph:
72
+ repo_path = repo_path.resolve()
73
+ graph = RepoGraph()
74
+ repo_id = f"repo:{repo_path.name}"
75
+ graph.add_node(
76
+ Node(id=repo_id, kind=NodeKind.Repository, name=repo_path.name, path=str(repo_path))
77
+ )
78
+ files: list[Path] = []
79
+ for ext in self._extensions():
80
+ files.extend(repo_path.rglob(f"*{ext}"))
81
+ for source_file in sorted(set(files)):
82
+ if self._should_skip(source_file.relative_to(repo_path)):
83
+ continue
84
+ adapter = self._forced or adapter_for_extension(source_file.suffix)
85
+ if adapter is not None:
86
+ self._ingest_file(graph, repo_path, repo_id, source_file, adapter)
87
+ return graph
88
+
89
+ def _should_skip(self, rel: Path) -> bool:
90
+ for part in rel.parts:
91
+ if part.startswith("."):
92
+ return True
93
+ if part in self._ignore:
94
+ return True
95
+ return False
96
+
97
+ def _ingest_file(
98
+ self,
99
+ graph: RepoGraph,
100
+ repo_path: Path,
101
+ repo_id: str,
102
+ file_path: Path,
103
+ adapter: LanguageAdapter,
104
+ ) -> None:
105
+ rel = file_path.relative_to(repo_path)
106
+ rel_str = str(rel)
107
+ module_name = ".".join(rel.with_suffix("").parts)
108
+ file_id = f"file:{rel_str}"
109
+ module_id = f"module:{module_name}"
110
+
111
+ source = file_path.read_bytes()
112
+ root = parse_cached(adapter, source)
113
+
114
+ graph.add_node(
115
+ Node(
116
+ id=file_id,
117
+ kind=NodeKind.File,
118
+ name=rel_str,
119
+ path=rel_str,
120
+ start_line=1,
121
+ end_line=root.end_point[0] + 1,
122
+ )
123
+ )
124
+ graph.add_edge(Edge(src=repo_id, dst=file_id, kind=EdgeKind.CONTAINS))
125
+
126
+ graph.add_node(
127
+ Node(
128
+ id=module_id,
129
+ kind=NodeKind.Module,
130
+ name=module_name,
131
+ path=rel_str,
132
+ start_line=1,
133
+ end_line=root.end_point[0] + 1,
134
+ attrs={"language": adapter.name},
135
+ )
136
+ )
137
+ graph.add_edge(Edge(src=file_id, dst=module_id, kind=EdgeKind.CONTAINS))
138
+
139
+ for decl in adapter.module_declarations(root, source, module_name, rel_str):
140
+ if isinstance(decl, FunctionDecl):
141
+ self._add_function(graph, module_id, module_name, rel_str, decl, is_method=False)
142
+ elif isinstance(decl, ClassDecl):
143
+ self._add_class(graph, module_id, module_name, rel_str, decl)
144
+ elif isinstance(decl, ImportDecl):
145
+ self._add_import(graph, module_id, rel_str, decl)
146
+ elif isinstance(decl, VariableDecl):
147
+ self._add_variable(graph, module_id, module_name, rel_str, decl)
148
+
149
+ def _add_function(
150
+ self,
151
+ graph: RepoGraph,
152
+ parent_id: str,
153
+ parent_qual: str,
154
+ rel_path: str,
155
+ decl: FunctionDecl,
156
+ is_method: bool,
157
+ ) -> None:
158
+ qual = f"{parent_qual}.{decl.name}"
159
+ kind = NodeKind.Method if is_method else NodeKind.Function
160
+ prefix = "method" if is_method else "func"
161
+ node_id = f"{prefix}:{qual}"
162
+ graph.add_node(
163
+ Node(
164
+ id=node_id,
165
+ kind=kind,
166
+ name=decl.name,
167
+ path=rel_path,
168
+ start_line=decl.node.start_point[0] + 1,
169
+ end_line=decl.node.end_point[0] + 1,
170
+ attrs={
171
+ "qualname": qual,
172
+ "signature": decl.signature,
173
+ "returns": decl.returns,
174
+ "decorators": list(decl.decorators),
175
+ "doc": decl.doc,
176
+ "raises": list(decl.raises),
177
+ "free_names": list(decl.free_names),
178
+ },
179
+ )
180
+ )
181
+ graph.add_edge(Edge(src=parent_id, dst=node_id, kind=EdgeKind.CONTAINS))
182
+ for param in decl.params:
183
+ self._add_parameter(graph, node_id, qual, rel_path, param.name, param.node)
184
+
185
+ def _add_parameter(
186
+ self,
187
+ graph: RepoGraph,
188
+ func_id: str,
189
+ func_qual: str,
190
+ rel_path: str,
191
+ name: str,
192
+ ts_node: TSNode,
193
+ ) -> None:
194
+ param_id = f"param:{func_qual}.{name}"
195
+ graph.add_node(
196
+ Node(
197
+ id=param_id,
198
+ kind=NodeKind.Parameter,
199
+ name=name,
200
+ path=rel_path,
201
+ start_line=ts_node.start_point[0] + 1,
202
+ end_line=ts_node.end_point[0] + 1,
203
+ )
204
+ )
205
+ graph.add_edge(Edge(src=func_id, dst=param_id, kind=EdgeKind.CONTAINS))
206
+
207
+ def _add_class(
208
+ self,
209
+ graph: RepoGraph,
210
+ parent_id: str,
211
+ parent_qual: str,
212
+ rel_path: str,
213
+ decl: ClassDecl,
214
+ ) -> None:
215
+ qual = f"{parent_qual}.{decl.name}"
216
+ node_id = f"class:{qual}"
217
+ graph.add_node(
218
+ Node(
219
+ id=node_id,
220
+ kind=NodeKind.Class,
221
+ name=decl.name,
222
+ path=rel_path,
223
+ start_line=decl.node.start_point[0] + 1,
224
+ end_line=decl.node.end_point[0] + 1,
225
+ attrs={"qualname": qual, "fields": dict(decl.fields)},
226
+ )
227
+ )
228
+ graph.add_edge(Edge(src=parent_id, dst=node_id, kind=EdgeKind.CONTAINS))
229
+ for method in decl.methods:
230
+ self._add_function(graph, node_id, qual, rel_path, method, is_method=True)
231
+
232
+ def _add_import(
233
+ self, graph: RepoGraph, module_id: str, rel_path: str, decl: ImportDecl
234
+ ) -> None:
235
+ import_id = f"import:{module_id}::{decl.target}"
236
+ graph.add_node(
237
+ Node(
238
+ id=import_id,
239
+ kind=NodeKind.Import,
240
+ name=decl.target,
241
+ path=rel_path,
242
+ start_line=decl.node.start_point[0] + 1,
243
+ end_line=decl.node.end_point[0] + 1,
244
+ attrs={"target": decl.target, "alias": decl.alias},
245
+ )
246
+ )
247
+ graph.add_edge(Edge(src=module_id, dst=import_id, kind=EdgeKind.CONTAINS))
248
+ graph.add_edge(
249
+ Edge(src=module_id, dst=import_id, kind=EdgeKind.IMPORTS, attrs={"target": decl.target})
250
+ )
251
+
252
+ def _add_variable(
253
+ self, graph: RepoGraph, module_id: str, module_name: str, rel_path: str, decl: VariableDecl
254
+ ) -> None:
255
+ var_id = f"var:{module_name}.{decl.name}"
256
+ graph.add_node(
257
+ Node(
258
+ id=var_id,
259
+ kind=NodeKind.Variable,
260
+ name=decl.name,
261
+ path=rel_path,
262
+ start_line=decl.node.start_point[0] + 1,
263
+ end_line=decl.node.end_point[0] + 1,
264
+ attrs={"qualname": f"{module_name}.{decl.name}"},
265
+ )
266
+ )
267
+ graph.add_edge(Edge(src=module_id, dst=var_id, kind=EdgeKind.CONTAINS))
268
+
269
+
270
+ __all__ = ["DEFAULT_IGNORE_DIRS", "TreeSitterSource"]
cgir/trace/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Trace map: source-loc ↔ ComponentSpec id."""
2
+
3
+ from cgir.trace.trace_map import TraceMap, build_trace_map
4
+
5
+ __all__ = ["TraceMap", "build_trace_map"]