codegraph-network 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.
codegraph/links.py ADDED
@@ -0,0 +1,220 @@
1
+ """Hidden links that are not explicit in code: tests <-> subject, shared string keys, git co-change.
2
+
3
+ Everything here is derived from the index plus (optionally) `git log`; none of it needs a model.
4
+ Symbol-level results go to the `links` table and file-level ones to `file_links`, never to `edges`,
5
+ so callers / callees / impact stay purely structural.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import posixpath
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ from collections import defaultdict
14
+ from itertools import combinations
15
+ from pathlib import Path
16
+
17
+ from .store import TARGET_KINDS_CALL, Store, split_words
18
+
19
+ # ---------------------------------------------------------------------------------- tests <-> subject
20
+ TEST_DIR = re.compile(r"(^|/)(tests?|__tests__|spec|specs)/")
21
+ # (pattern, group holding the subject stem) per naming convention
22
+ TEST_NAMES = (
23
+ re.compile(r"^test_(.+)\.py$"), re.compile(r"^(.+)_test\.py$"),
24
+ re.compile(r"^(.+)\.(?:test|spec)\.[cm]?[jt]sx?$"),
25
+ re.compile(r"^(.+)_test\.(?:go|rs|cpp|cc|cxx)$"), re.compile(r"^test_(.+)\.(?:cpp|cc|cxx|c)$"),
26
+ )
27
+ TEST_SYM = re.compile(r"^(?:test_?|Test_?)(.+?)(?:_?[Tt]ests?)?$")
28
+
29
+
30
+ def is_test_path(path: str) -> bool:
31
+ return bool(TEST_DIR.search(path)) or subject_stem(path) is not None
32
+
33
+
34
+ def subject_stem(path: str) -> str | None:
35
+ """`tests/test_store.py` -> `store`, `a/foo.test.ts` -> `foo`, `foo_test.go` -> `foo`."""
36
+ base = posixpath.basename(path)
37
+ for pat in TEST_NAMES:
38
+ m = pat.match(base)
39
+ if m:
40
+ return m.group(1)
41
+ return None
42
+
43
+
44
+ def _subject_file(test: str, by_stem: dict[str, list[str]]) -> str | None:
45
+ """The non-test file a test file is named after; the nearest one wins when several share a stem."""
46
+ stem = subject_stem(test)
47
+ cands = [f for f in by_stem.get(stem or "", ()) if not is_test_path(f)]
48
+ if not cands:
49
+ return None
50
+
51
+ def shared(f: str) -> int:
52
+ n = 0
53
+ for a, b in zip(test.split("/"), f.split("/"), strict=False):
54
+ if a != b:
55
+ break
56
+ n += 1
57
+ return n
58
+
59
+ best = max(shared(f) for f in cands)
60
+ top = [f for f in cands if shared(f) == best]
61
+ return top[0] if len(top) == 1 else None
62
+
63
+
64
+ def _name_words(name: str) -> list[str]:
65
+ return [w.lower() for w in split_words(name).split() if w]
66
+
67
+
68
+ def _subject_symbol(test_name: str, candidates: dict[str, int]) -> int | None:
69
+ """`test_resolve_import_roots` -> the longest prefix that names a symbol: resolve_import_roots,
70
+ resolve_import, resolve. `candidates` maps a normalised name to a symbol id."""
71
+ m = TEST_SYM.match(test_name)
72
+ words = _name_words(m.group(1) if m else test_name)
73
+ for n in range(len(words), 0, -1):
74
+ hit = candidates.get("".join(words[:n]))
75
+ if hit is not None and (n > 1 or len(words[0]) >= 3): # a lone tiny word is too weak a signal
76
+ return hit
77
+ return None
78
+
79
+
80
+ def link_tests(db) -> int:
81
+ """Test file <-> subject file by naming convention, and `test_x` -> `x` inside that pair."""
82
+ db.execute("DELETE FROM links WHERE kind='tests'")
83
+ files = [r[0] for r in db.execute("SELECT path FROM files")]
84
+ by_stem: dict[str, list[str]] = defaultdict(list)
85
+ for f in files:
86
+ by_stem[posixpath.splitext(posixpath.basename(f))[0]].append(f)
87
+ syms: dict[str, list] = defaultdict(list)
88
+ for r in db.execute("SELECT id, file, name, kind FROM symbols WHERE kind != 'impl'"):
89
+ syms[r["file"]].append(r)
90
+ rows = []
91
+ for t in files:
92
+ subject = _subject_file(t, by_stem)
93
+ if subject is None:
94
+ continue
95
+ tmod = next((s["id"] for s in syms[t] if s["kind"] == "module"), None)
96
+ smod = next((s["id"] for s in syms[subject] if s["kind"] == "module"), None)
97
+ if tmod is not None and smod is not None:
98
+ rows.append((tmod, smod, "tests", 1.0, "file"))
99
+ cands = {"".join(_name_words(s["name"])): s["id"] for s in syms[subject]
100
+ if s["kind"] in TARGET_KINDS_CALL}
101
+ for s in syms[t]:
102
+ if s["kind"] in ("function", "method", "class") and (s["name"].lower().startswith("test") or
103
+ s["name"].lower().endswith("test")):
104
+ hit = _subject_symbol(s["name"], cands)
105
+ if hit is not None:
106
+ rows.append((s["id"], hit, "tests", 1.0, "name"))
107
+ db.executemany("INSERT INTO links VALUES(?,?,?,?,?)", rows)
108
+ return len(rows)
109
+
110
+
111
+ # ---------------------------------------------------------------------------------- string keys
112
+ # namespace -> (role on one side, role on the other). Same role twice: everything sharing the key is related.
113
+ KEY_ROLES = {"route": ("provides", "uses"), "event": ("emits", "listens"), "config": ("reads", "reads")}
114
+ MAX_KEY_GROUP = 8 # a key shared by more symbols than this is a convention, not a link
115
+
116
+
117
+ def link_keys(db) -> int:
118
+ """Link symbols in different files that use the same string key in matching roles."""
119
+ db.execute("DELETE FROM links WHERE kind='key'")
120
+ groups: dict[tuple[str, str], dict[str, set[int]]] = defaultdict(lambda: defaultdict(set))
121
+ for r in db.execute("SELECT src, ns, key, role FROM raw_keys"):
122
+ groups[(r["ns"], r["key"])][r["role"]].add(r["src"])
123
+ file_of = {r[0]: r[1] for r in db.execute("SELECT id, file FROM symbols")}
124
+ rows = []
125
+ for (ns, key), roles in groups.items():
126
+ a_role, b_role = KEY_ROLES.get(ns, (None, None))
127
+ if a_role is None:
128
+ continue
129
+ a, b = roles.get(a_role, set()), roles.get(b_role, set())
130
+ if a_role == b_role:
131
+ pairs = combinations(sorted(a), 2) if 2 <= len(a) <= MAX_KEY_GROUP else ()
132
+ elif a and b and len(a) <= MAX_KEY_GROUP and len(b) <= MAX_KEY_GROUP:
133
+ pairs = ((x, y) for x in sorted(a) for y in sorted(b))
134
+ else:
135
+ pairs = ()
136
+ note = f"{ns}:{key}"[:60]
137
+ rows += [(x, y, "key", 1.0, note) for x, y in pairs if file_of.get(x) != file_of.get(y)]
138
+ db.executemany("INSERT INTO links VALUES(?,?,?,?,?)", rows)
139
+ return len(rows)
140
+
141
+
142
+ # ---------------------------------------------------------------------------------- git co-change
143
+ GIT_COMMITS = 1000 # history window
144
+ MAX_COMMIT_FILES = 30 # bigger commits (renames, reformatting, vendoring) say nothing about coupling
145
+ MIN_COMMITS = 3 # a pair must co-change at least this often: twice is a coincidence
146
+ MIN_WEIGHT = 0.3
147
+ MAX_PARTNERS = 8
148
+
149
+
150
+ def _git(root: Path, *args: str) -> str | None:
151
+ """stdout of a git command in `root`, or None when git is missing / this is not a repository."""
152
+ if shutil.which("git") is None:
153
+ return None
154
+ try:
155
+ r = subprocess.run(["git", "-C", str(root), "-c", "core.quotepath=off", *args],
156
+ capture_output=True, text=True, timeout=60)
157
+ except (OSError, subprocess.SubprocessError):
158
+ return None
159
+ return r.stdout if r.returncode == 0 else None
160
+
161
+
162
+ def cochange_pairs(log: str, known: set[str]) -> dict[tuple[str, str], float]:
163
+ """Parse `git log --name-only` output (commits separated by a \\x1e line) into pair weights.
164
+
165
+ A pair's weight is its co-change count over sqrt(commits of a * commits of b) (Ochiai), so a file
166
+ touched in every commit does not become everyone's partner, shrunk by n/(n+2) so that pairs seen
167
+ only a few times rank below pairs with a long shared history.
168
+ """
169
+ touched: dict[str, int] = defaultdict(int)
170
+ pair_n: dict[tuple[str, str], int] = defaultdict(int)
171
+ for commit in log.split("\x1e"):
172
+ names = [f for f in commit.split("\n") if f]
173
+ files = sorted({f for f in names if f in known})
174
+ if len(files) < 2 or len(names) > MAX_COMMIT_FILES: # size counts every file, indexed or not
175
+ continue
176
+ for f in files:
177
+ touched[f] += 1
178
+ for a, b in combinations(files, 2):
179
+ pair_n[(a, b)] += 1
180
+ out = {}
181
+ for (a, b), n in pair_n.items():
182
+ if n >= MIN_COMMITS:
183
+ weight = n / (touched[a] * touched[b]) ** 0.5 * n / (n + 2)
184
+ if weight >= MIN_WEIGHT:
185
+ out[(a, b)] = weight
186
+ return out
187
+
188
+
189
+ def link_cochange(store: Store, root: Path, force: bool = False) -> int:
190
+ """Refresh file-level co-change edges when HEAD moved. No git, no history or no repo: no edges."""
191
+ head = _git(root, "rev-parse", "HEAD")
192
+ head = head.strip() if head else ""
193
+ if not head:
194
+ if store.get_meta("git_head"): # was a repo, no longer is: drop the stale edges
195
+ store.db.execute("DELETE FROM file_links WHERE kind='cochange'")
196
+ store.set_meta("git_head", "")
197
+ return 0
198
+ if not force and store.get_meta("git_head") == head:
199
+ return -1 # unchanged
200
+ log = _git(root, "log", f"-n{GIT_COMMITS}", "--no-merges", "--relative", "--name-only",
201
+ "--pretty=format:%x1e")
202
+ known = {r[0] for r in store.db.execute("SELECT path FROM files")}
203
+ pairs = cochange_pairs(log or "", known)
204
+ # keep each file's strongest partners only, so a hub file cannot fan out to the whole repo
205
+ per_file: dict[str, list[tuple[float, tuple[str, str]]]] = defaultdict(list)
206
+ for pair, w in pairs.items():
207
+ per_file[pair[0]].append((w, pair))
208
+ per_file[pair[1]].append((w, pair))
209
+ keep = {p for lst in per_file.values() for _, p in sorted(lst, reverse=True)[:MAX_PARTNERS]}
210
+ store.db.execute("DELETE FROM file_links WHERE kind='cochange'")
211
+ store.db.executemany("INSERT INTO file_links VALUES(?,?,?,?)",
212
+ [(a, b, "cochange", pairs[(a, b)]) for a, b in sorted(keep)])
213
+ store.set_meta("git_head", head)
214
+ return len(keep)
215
+
216
+
217
+ def link_static(db) -> dict:
218
+ """The cheap, model-free symbol links; rebuilt wholesale whenever the index changed."""
219
+ return {"tests": link_tests(db), "key": link_keys(db)}
220
+
codegraph/parser.py ADDED
@@ -0,0 +1,459 @@
1
+ """Tree-sitter based extraction of symbols and relations (no LLM involved)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass, field
6
+
7
+ from tree_sitter_language_pack import get_parser
8
+
9
+ from . import keys
10
+
11
+ IDENT = re.compile(r"[^\W\d][\w]*", re.UNICODE)
12
+ CONST_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$")
13
+
14
+ EXT = {
15
+ ".py": "python",
16
+ ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
17
+ ".ts": "typescript", ".tsx": "tsx",
18
+ ".go": "go",
19
+ ".rs": "rust",
20
+ ".c": "cpp", ".h": "cpp", ".cc": "cpp", ".cpp": "cpp", ".cxx": "cpp", ".hpp": "cpp", ".hh": "cpp",
21
+ }
22
+
23
+ _JS = {
24
+ "function_declaration": "function",
25
+ "generator_function_declaration": "function",
26
+ "class_declaration": "class",
27
+ "method_definition": "method",
28
+ "pair": "method", # `get x() {}` / `set x(v) {}` in an object literal
29
+ "public_field_definition": "field",
30
+ }
31
+ _TS = {
32
+ **_JS,
33
+ "abstract_class_declaration": "class",
34
+ "interface_declaration": "interface",
35
+ "type_alias_declaration": "type",
36
+ "enum_declaration": "enum",
37
+ "method_signature": "method", # `m(x: T): R;` inside an interface
38
+ "property_signature": "field",
39
+ }
40
+ DEFS = {
41
+ "python": {"function_definition": "function", "class_definition": "class"},
42
+ "javascript": _JS,
43
+ "typescript": _TS,
44
+ "tsx": _TS,
45
+ "go": {"function_declaration": "function", "method_declaration": "method",
46
+ "type_spec": "type", "method_elem": "method"},
47
+ "rust": {
48
+ "function_item": "function", "struct_item": "struct", "enum_item": "enum",
49
+ "trait_item": "trait", "impl_item": "impl", "mod_item": "module_decl",
50
+ "trait_item_signature": "method", "function_signature_item": "function",
51
+ },
52
+ "cpp": {
53
+ "function_definition": "function", "class_specifier": "class", "struct_specifier": "struct",
54
+ "namespace_definition": "namespace", "enum_specifier": "enum", "type_definition": "type",
55
+ },
56
+ }
57
+ # node type -> field holding the callee
58
+ CALLS = {
59
+ "python": {"call": "function"},
60
+ "javascript": {"call_expression": "function", "new_expression": "constructor"},
61
+ "typescript": {"call_expression": "function", "new_expression": "constructor"},
62
+ "tsx": {"call_expression": "function", "new_expression": "constructor"},
63
+ "go": {"call_expression": "function"},
64
+ "rust": {"call_expression": "function", "macro_invocation": "macro"},
65
+ "cpp": {"call_expression": "function"},
66
+ }
67
+ IMPORTS = {
68
+ "python": {"import_statement", "import_from_statement"},
69
+ "javascript": {"import_statement"},
70
+ "typescript": {"import_statement"},
71
+ "tsx": {"import_statement"},
72
+ "go": {"import_spec"},
73
+ "rust": {"use_declaration"},
74
+ "cpp": {"preproc_include"},
75
+ }
76
+ # callee shapes like `obj.name()`: the receiver type is unknown, so the target is only a guess
77
+ ATTR_CALLEES = {"attribute", "member_expression", "field_expression", "selector_expression",
78
+ "scoped_identifier", "qualified_name"}
79
+ JSLIKE = {"javascript", "typescript", "tsx"}
80
+ FUNC_VALUES = {"arrow_function", "function_expression", "function", "generator_function"}
81
+ CONTAINER_KINDS = {"class", "impl", "struct", "trait", "interface", "namespace"}
82
+ HERITAGE = {"class_heritage", "extends_type_clause", "extends_clause", "implements_clause",
83
+ "base_class_clause", "superclasses", "type_parameters"}
84
+ # subtrees inside a heritage clause that must never contribute a name (generic arguments,
85
+ # type parameters) -- this is what used to turn `extends Base<T>` into a phantom `T` edge
86
+ SKIP_SUBTREES = {"type_arguments", "type_parameters", "type_parameter", "type_parameter_list",
87
+ "modifiers", "attribute_item", "variance", "constraints"}
88
+ NAME_NODES = {"identifier", "type_identifier", "field_identifier", "constant", "property_identifier"}
89
+ KW = {"extends", "implements", "public", "private", "protected", "virtual", "class", "struct",
90
+ "template", "typename", "using", "interface", "enum", "namespace", "operator"}
91
+
92
+
93
+ @dataclass
94
+ class Sym:
95
+ id: int
96
+ name: str
97
+ qualname: str
98
+ kind: str
99
+ start: int
100
+ end: int
101
+ sig: str
102
+ doc: str
103
+ parent: int | None
104
+
105
+
106
+ @dataclass
107
+ class Parsed:
108
+ syms: list[Sym] = field(default_factory=list)
109
+ refs: list[tuple[int, str, str]] = field(default_factory=list) # (src local id, name, kind)
110
+ imports: list[str] = field(default_factory=list)
111
+ keys: list[tuple[int, str, str, str]] = field(default_factory=list) # (src local id, ns, key, role)
112
+
113
+
114
+ _parsers: dict = {}
115
+
116
+
117
+ def _parser(lang: str):
118
+ if lang not in _parsers:
119
+ _parsers[lang] = get_parser(lang)
120
+ return _parsers[lang]
121
+
122
+
123
+ def _clean_comment(raw: str) -> str:
124
+ """Turn any supported comment syntax into its first line of prose."""
125
+ for line in raw.splitlines():
126
+ line = line.strip()
127
+ for prefix in ("///", "//!", "//", "/**", "/*", "*/", "///<", "//!<", "*", "#!", "#", "<!--", "-->"):
128
+ if line.startswith(prefix):
129
+ line = line[len(prefix):].strip()
130
+ break
131
+ line = line.strip(" \t*/#!\"'").strip()
132
+ if line and set(line) != {"*"}:
133
+ return line[:160]
134
+ return ""
135
+
136
+
137
+ def _first_line(s: str) -> str:
138
+ return _clean_comment(s)
139
+
140
+
141
+ class _FileParser:
142
+ """Walks one file's syntax tree once and collects symbols, references and imports."""
143
+
144
+ def __init__(self, src: bytes, lang: str, modname: str):
145
+ self.src = src
146
+ self.lang = lang
147
+ self.modname = modname
148
+ self.out = Parsed()
149
+ self.defs = DEFS[lang]
150
+ self.calls = CALLS[lang]
151
+ self.imports_set = IMPORTS[lang]
152
+
153
+ # ---- small helpers ----
154
+ def text(self, n) -> str:
155
+ return self.src[n.start_byte:n.end_byte].decode("utf8", "replace")
156
+
157
+ def field(self, n, *names):
158
+ for nm in names:
159
+ c = n.child_by_field_name(nm)
160
+ if c is not None:
161
+ return c
162
+ return None
163
+
164
+ @staticmethod
165
+ def last_ident(s: str) -> str | None:
166
+ ids = IDENT.findall(s)
167
+ return ids[-1] if ids else None
168
+
169
+ # ---- naming ----
170
+ def get_name(self, n) -> tuple[str | None, str | None]:
171
+ """Return (name, owner_override) for a definition node."""
172
+ t = n.type
173
+ if t == "impl_item":
174
+ ty = self.field(n, "type")
175
+ ids = IDENT.findall(self.text(ty)) if ty is not None else []
176
+ return (ids[0] if ids else None), None
177
+ if self.lang == "cpp" and t == "function_definition":
178
+ d = self.field(n, "declarator")
179
+ while d is not None and self.field(d, "declarator") is not None:
180
+ d = self.field(d, "declarator")
181
+ if d is None:
182
+ return None, None
183
+ ids = IDENT.findall(self.text(d))
184
+ if not ids:
185
+ return None, None
186
+ owner = ids[-2] if "::" in self.text(d) and len(ids) > 1 else None
187
+ return ids[-1], owner
188
+ if self.lang == "go" and t == "method_declaration":
189
+ nm = self.field(n, "name")
190
+ rc = self.field(n, "receiver")
191
+ owner = self.last_ident(self.text(rc)) if rc is not None else None
192
+ return (self.text(nm) if nm is not None else None), owner
193
+ if t in ("method_signature", "property_signature", "pair", "public_field_definition"):
194
+ nm = self.field(n, "name", "key", "property")
195
+ if nm is None or nm.type in ("string", "string_literal", "computed_property_name"):
196
+ return None, None # `{"a-b": fn}` has no identifier to link
197
+ return self.last_ident(self.text(nm)), None
198
+ if t == "assignment" and self.lang == "python":
199
+ left = self.field(n, "left")
200
+ return (self.text(left) if left is not None and left.type == "identifier" else None), None
201
+ nm = self.field(n, "name")
202
+ return (self.text(nm) if nm is not None else None), None
203
+
204
+ # ---- doc comments ----
205
+ def get_doc(self, n) -> str:
206
+ if self.lang == "python":
207
+ body = self.field(n, "body")
208
+ if body is not None and body.named_children:
209
+ f = body.named_children[0]
210
+ if f.type == "expression_statement" and f.named_children and f.named_children[0].type == "string":
211
+ return _clean_comment(self.text(f.named_children[0]))
212
+ return ""
213
+ # collect the run of comment lines directly above the definition
214
+ parts: list[str] = []
215
+ p = n.prev_named_sibling
216
+ while p is not None and "comment" in p.type and p.end_point[0] >= n.start_point[0] - 1:
217
+ line = _clean_comment(self.text(p))
218
+ if line:
219
+ parts.insert(0, line)
220
+ p = p.prev_named_sibling
221
+ return " ".join(parts)[:160]
222
+
223
+ # ---- signatures ----
224
+ def sig(self, n) -> str:
225
+ """Header of the definition: the first line, extended while parentheses stay unbalanced."""
226
+ lines = self.text(n).split("\n")
227
+ head = lines[0].strip()
228
+ for extra in lines[1:4]: # a signature may wrap over a few lines
229
+ if head.count("(") <= head.count(")"):
230
+ break
231
+ head = (head + " " + extra.strip()).strip()
232
+ return head[:160]
233
+
234
+ # ---- inheritance ----
235
+ def _collect_ids(self, n, bucket: list[str], depth: int = 0):
236
+ if depth > 8 or n.type in SKIP_SUBTREES:
237
+ return
238
+ if n.type in NAME_NODES:
239
+ txt = self.text(n)
240
+ if txt and txt not in KW:
241
+ bucket.append(txt)
242
+ return
243
+ for c in n.children:
244
+ self._collect_ids(c, bucket, depth + 1)
245
+
246
+ def _clause_names(self, clause, sid: int):
247
+ """Each comma-separated entry of a heritage clause contributes exactly one name:
248
+ the last identifier in its subtree, so `ns::Outer` and `Base<T>` both resolve right."""
249
+ bucket: list[str] = []
250
+
251
+ def flush():
252
+ if bucket:
253
+ name = bucket[-1]
254
+ if name not in KW:
255
+ self.out.refs.append((sid, name, "inherits"))
256
+ bucket.clear()
257
+
258
+ for c in clause.children:
259
+ # keywords (`extends`, `implements`, access specifiers) end the current entry; note that
260
+ # `extends_clause` / `implements_clause` are containers and must be descended into
261
+ if c.type == "," or c.type in KW:
262
+ flush()
263
+ continue
264
+ self._collect_ids(c, bucket)
265
+ flush()
266
+
267
+ def collect_inherits(self, n, sid: int):
268
+ lang = self.lang
269
+ if lang == "python" and n.type == "class_definition":
270
+ sc = self.field(n, "superclasses")
271
+ if sc is not None:
272
+ for c in sc.named_children: # python keeps one entry per base, no clause commas
273
+ bucket: list[str] = []
274
+ self._collect_ids(c, bucket)
275
+ if bucket and bucket[-1] not in KW:
276
+ self.out.refs.append((sid, bucket[-1], "inherits"))
277
+ elif lang in JSLIKE and n.type in ("class_declaration", "abstract_class_declaration",
278
+ "interface_declaration"):
279
+ for c in n.children:
280
+ if c.type in HERITAGE:
281
+ self._clause_names(c, sid)
282
+ elif lang == "cpp" and n.type in ("class_specifier", "struct_specifier"):
283
+ for c in n.children:
284
+ if c.type == "base_class_clause":
285
+ self._clause_names(c, sid)
286
+ elif lang == "rust" and n.type == "impl_item":
287
+ tr = self.field(n, "trait")
288
+ if tr is not None:
289
+ bucket: list[str] = []
290
+ self._collect_ids(tr, bucket)
291
+ if bucket and bucket[-1] not in KW:
292
+ self.out.refs.append((sid, bucket[-1], "inherits"))
293
+ # Go interfaces have no base clause; their methods are indexed as method_elem
294
+
295
+ # ---- imports ----
296
+ def import_raws(self, n) -> list[str]:
297
+ t = n.type
298
+ lang = self.lang
299
+ if lang == "python":
300
+ if t == "import_from_statement":
301
+ m = self.field(n, "module_name")
302
+ if m is None:
303
+ return []
304
+ mod = self.text(m)
305
+ # `from app import util` may mean the submodule app/util.py rather than the
306
+ # package itself, so offer both readings and let the store pick what exists
307
+ cands = [mod]
308
+ seen_import = False
309
+ for c in n.children: # only what follows the `import` keyword
310
+ if c.type == "import":
311
+ seen_import = True
312
+ continue
313
+ if not seen_import:
314
+ continue
315
+ if c.type == "dotted_name":
316
+ cands.append(f"{mod}.{self.text(c)}")
317
+ elif c.type == "aliased_import":
318
+ target = self.field(c, "name")
319
+ if target is not None and target.type != "dotted_name":
320
+ cands.append(f"{mod}.{self.text(target)}")
321
+ return cands
322
+ res = []
323
+ for c in n.named_children:
324
+ if c.type == "aliased_import":
325
+ c = self.field(c, "name") or c
326
+ res.append(self.text(c))
327
+ return res
328
+ if lang in JSLIKE:
329
+ s = self.field(n, "source")
330
+ return [self.text(s).strip("'\"`")] if s is not None else []
331
+ if lang == "go":
332
+ p = self.field(n, "path")
333
+ return [self.text(p).strip('"`')] if p is not None else []
334
+ if lang == "rust":
335
+ a = self.field(n, "argument")
336
+ return [self.text(a)] if a is not None else []
337
+ if lang == "cpp":
338
+ p = self.field(n, "path")
339
+ return [self.text(p).strip('<>"\'')] if p is not None else []
340
+ return []
341
+
342
+ # ---- calls ----
343
+ def is_self_call(self, f) -> bool:
344
+ """`self.x()` / `cls.x()` (python), `this.x()` (js/ts/c++), `self.x()` (rust)."""
345
+ if f.type == "attribute":
346
+ o = self.field(f, "object")
347
+ return o is not None and o.type == "identifier" and self.text(o) in ("self", "cls")
348
+ if f.type == "member_expression":
349
+ o = self.field(f, "object")
350
+ return o is not None and o.type == "this"
351
+ if f.type == "field_expression":
352
+ o = self.field(f, "value", "argument")
353
+ return o is not None and o.end_byte - o.start_byte <= 4 and self.text(o) in ("self", "this")
354
+ return False
355
+
356
+ # ---- string-keyed links (routes, events, config keys; see keys.py) ----
357
+ def first_string_arg(self, call) -> str | None:
358
+ args = self.field(call, "arguments")
359
+ if args is None or not args.named_children:
360
+ return None
361
+ a = args.named_children[0]
362
+ if a.type not in ("string", "template_string", "string_literal"):
363
+ return None
364
+ return keys.string_value(self.text(a))
365
+
366
+ def collect_key(self, call, callee, src: int, decorator: bool = False):
367
+ if self.lang not in keys.ANY:
368
+ return
369
+ key = self.first_string_arg(call)
370
+ if key is not None:
371
+ for ns, k, role in keys.match(self.lang, self.text(callee), key, decorator):
372
+ self.out.keys.append((src, ns, k, role))
373
+
374
+ def collect_decorator_keys(self, defn, sid: int):
375
+ """`@app.get("/x")` registers the *decorated* function, not the scope the decorator sits in."""
376
+ parent = defn.parent
377
+ if self.lang != "python" or parent is None or parent.type != "decorated_definition":
378
+ return
379
+ for dec in parent.children:
380
+ call = next((c for c in dec.named_children if c.type == "call"), None) if dec.type == "decorator" else None
381
+ callee = self.field(call, "function") if call is not None else None
382
+ if callee is not None:
383
+ self.collect_key(call, callee, sid, decorator=True)
384
+
385
+ # ---- main walk (iterative: no recursion limit games) ----
386
+ def run(self) -> Parsed:
387
+ tree = _parser(self.lang).parse(self.src)
388
+ nlines = self.src.count(b"\n") + 1
389
+ self.out.syms.append(Sym(0, self.modname, "", "module", 1, nlines, "", "", None))
390
+ stack = [(tree.root_node, 0)]
391
+ while stack:
392
+ n, cur = stack.pop()
393
+ t = n.type
394
+ kind = self.defs.get(t)
395
+ if kind is None and self.lang in JSLIKE and t == "variable_declarator":
396
+ kind = self._js_declarator_kind(n, cur)
397
+ elif kind is None and self.lang == "python" and t == "assignment":
398
+ kind = self._py_assignment_kind(n, cur)
399
+ new = cur
400
+ if kind:
401
+ name, owner = self.get_name(n)
402
+ if name:
403
+ parent = self.out.syms[cur]
404
+ prefix = owner or parent.qualname
405
+ qn = f"{prefix}.{name}" if prefix else name
406
+ if kind == "function" and parent.kind in CONTAINER_KINDS:
407
+ kind = "method"
408
+ # `int Shape::area() {}` at file scope: the owner comes from the declarator
409
+ if kind == "function" and owner:
410
+ kind = "method"
411
+ if kind == "module_decl":
412
+ kind = "module"
413
+ s = Sym(len(self.out.syms), name, qn, kind,
414
+ n.start_point[0] + 1, n.end_point[0] + 1,
415
+ self.sig(n), self.get_doc(n), cur)
416
+ self.out.syms.append(s)
417
+ new = s.id
418
+ self.collect_inherits(n, s.id)
419
+ self.collect_decorator_keys(n, s.id)
420
+ cm = self.calls.get(t)
421
+ if cm:
422
+ f = self.field(n, cm)
423
+ if f is not None and f.end_byte - f.start_byte <= 120:
424
+ nm = self.last_ident(self.text(f))
425
+ if nm:
426
+ rk = ("calls_self" if self.is_self_call(f)
427
+ else "calls_attr" if f.type in ATTR_CALLEES else "calls")
428
+ self.out.refs.append((cur, nm, rk))
429
+ if not (n.parent is not None and n.parent.type == "decorator"): # handled at the def
430
+ self.collect_key(n, f, cur)
431
+ if t in self.imports_set:
432
+ self.out.imports.extend(self.import_raws(n))
433
+ for c in reversed(n.children):
434
+ stack.append((c, new))
435
+ return self.out
436
+
437
+ def _js_declarator_kind(self, n, cur) -> str | None:
438
+ parent = self.out.syms[cur]
439
+ v = self.field(n, "value")
440
+ if v is not None and v.type in FUNC_VALUES:
441
+ return "function"
442
+ if parent.kind in ("module",) or parent.kind in CONTAINER_KINDS:
443
+ return "variable"
444
+ return None
445
+
446
+ def _py_assignment_kind(self, n, cur) -> str | None:
447
+ """Only ALL_CAPS module-level bindings, so constants show up without flooding."""
448
+ parent = self.out.syms[cur]
449
+ if parent.kind not in ("module", "class"):
450
+ return None
451
+ targets = self.field(n, "left") or (n.named_children[0] if n.named_children else None)
452
+ if targets is None or targets.type != "identifier":
453
+ return None
454
+ txt = self.text(targets)
455
+ return "constant" if CONST_NAME.match(txt) else None
456
+
457
+
458
+ def parse(src: bytes, lang: str, modname: str) -> Parsed:
459
+ return _FileParser(src, lang, modname).run()
codegraph/py.typed ADDED
File without changes