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/indexer.py ADDED
@@ -0,0 +1,190 @@
1
+ """Incremental repository indexer."""
2
+ from __future__ import annotations
3
+
4
+ import fnmatch
5
+ import hashlib
6
+ import os
7
+ import sys
8
+ from concurrent.futures import ProcessPoolExecutor
9
+ from pathlib import Path
10
+
11
+ from . import community, embed, links
12
+ from .docs import DOC_EXT, DOC_LANGS, parse_doc
13
+ from .parser import EXT, parse
14
+ from .store import SCHEMA_VERSION, Store, recompute_rank
15
+
16
+ SKIP_DIRS = {"node_modules", "venv", "__pycache__", "dist", "build", "target", "vendor", "site-packages"}
17
+ MAX_BYTES = 1_000_000
18
+ # bump when parser output changes; forces a full re-index
19
+ PARSER_VERSION = 6 # 6: edge confidence, docs/SQL/PDF symbols
20
+ IGNORE_FILE = ".codegraphignore"
21
+ PARALLEL_MIN = 48 # below this, starting worker processes costs more than it saves
22
+ ALL_EXT = {**EXT, **DOC_EXT}
23
+
24
+
25
+ def db_path(root: Path) -> Path:
26
+ return root / ".codegraph" / "graph.db"
27
+
28
+
29
+ def load_ignore(root: Path) -> list[tuple[str, bool, bool, bool]]:
30
+ """Parse `.codegraphignore` (gitignore-style, minus `**` and escapes) into
31
+ (pattern, negated, dir_only, anchored) rules. Later rules win, as in gitignore."""
32
+ try:
33
+ text = (root / IGNORE_FILE).read_text(errors="replace")
34
+ except OSError:
35
+ return []
36
+ rules = []
37
+ for line in text.splitlines():
38
+ line = line.strip()
39
+ if not line or line.startswith("#"):
40
+ continue
41
+ neg = line.startswith("!")
42
+ line = line.lstrip("!")
43
+ dir_only = line.endswith("/")
44
+ line = line.strip("/") if dir_only else line
45
+ anchored = line.startswith("/") or "/" in line
46
+ if line:
47
+ rules.append((line.lstrip("/"), neg, dir_only, anchored))
48
+ return rules
49
+
50
+
51
+ def is_ignored(rel: str, is_dir: bool, rules) -> bool:
52
+ segs = rel.split("/")
53
+ hit = False
54
+ for pat, neg, dir_only, anchored in rules:
55
+ if anchored:
56
+ # the path itself, or any directory prefix of it
57
+ cands = ["/".join(segs[:i]) for i in range(1, len(segs) + 1)]
58
+ kinds = [True] * (len(segs) - 1) + [is_dir]
59
+ else:
60
+ cands, kinds = segs, [True] * (len(segs) - 1) + [is_dir]
61
+ if any(fnmatch.fnmatchcase(c, pat) and (k or not dir_only) for c, k in zip(cands, kinds, strict=True)):
62
+ hit = not neg
63
+ return hit
64
+
65
+
66
+ def _scan(root: Path) -> dict[str, tuple[Path, str]]:
67
+ seen: dict[str, tuple[Path, str]] = {}
68
+ rules = load_ignore(root)
69
+ for dirpath, dirs, files in os.walk(root):
70
+ rel_dir = Path(dirpath).relative_to(root).as_posix()
71
+ rel_dir = "" if rel_dir == "." else rel_dir + "/"
72
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")
73
+ and not (rules and is_ignored(rel_dir + d, True, rules))]
74
+ for f in files:
75
+ lang = ALL_EXT.get(Path(f).suffix.lower())
76
+ if not lang or (rules and is_ignored(rel_dir + f, False, rules)):
77
+ continue
78
+ p = Path(dirpath) / f
79
+ try:
80
+ if p.stat().st_size > MAX_BYTES:
81
+ continue
82
+ except OSError:
83
+ continue
84
+ seen[p.relative_to(root).as_posix()] = (p, lang)
85
+ return seen
86
+
87
+
88
+ def _parse_one(job):
89
+ """Worker: parse one file. Module level so it can be pickled into a process pool."""
90
+ data, lang, stem = job
91
+ try:
92
+ return (parse_doc if lang in DOC_LANGS else parse)(data, lang, stem), None
93
+ except Exception as e: # a broken file must not kill the index
94
+ return None, str(e)
95
+
96
+
97
+ def _parse_all(jobs: list) -> list:
98
+ """Parse in parallel across processes: tree-sitter plus the Python tree walk is CPU-bound, so
99
+ threads would not help. Falls back to serial if a pool cannot be started."""
100
+ if len(jobs) >= PARALLEL_MIN and (os.cpu_count() or 1) > 1:
101
+ try:
102
+ with ProcessPoolExecutor(max_workers=min(os.cpu_count() or 1, 8)) as pool:
103
+ return list(pool.map(_parse_one, jobs, chunksize=16))
104
+ except Exception as e: # sandboxes without fork/semaphores, broken pool, ...
105
+ print(f"codegraph: parallel parse unavailable ({e}); parsing serially", file=sys.stderr)
106
+ return [_parse_one(j) for j in jobs]
107
+
108
+
109
+ def index_repo(root: str | Path, force: bool = False, progress=None) -> dict:
110
+ """Update the index for `root`. Only changed files are re-parsed, and only the edges that
111
+ those changes can affect are re-resolved."""
112
+ root = Path(root).resolve()
113
+ store = Store(db_path(root))
114
+
115
+ def note(msg: str):
116
+ if progress is not None:
117
+ progress(msg)
118
+
119
+ # a database written by an older parser, or by an older schema, is rebuilt from scratch
120
+ if store.get_meta("parser_version") != str(PARSER_VERSION):
121
+ note("parser version changed, rebuilding index")
122
+ force = True
123
+
124
+ if force:
125
+ store.clear() # one bulk delete instead of one per file
126
+ seen = _scan(root)
127
+ old = store.file_hashes()
128
+ removed = set(old) - set(seen)
129
+
130
+ # hash first, so we know the full dirty set before mutating anything
131
+ to_parse: list[tuple[str, Path, str, bytes, str]] = []
132
+ for rel, (p, lang) in sorted(seen.items()):
133
+ try:
134
+ data = p.read_bytes()
135
+ except OSError:
136
+ continue
137
+ h = hashlib.sha1(data).hexdigest()
138
+ if not force and old.get(rel) == h:
139
+ continue
140
+ to_parse.append((rel, p, lang, data, h))
141
+
142
+ dirty_files = set(removed) | {rel for rel, *_ in to_parse}
143
+ # names are captured before deletion: a definition that disappears can invalidate edges
144
+ # elsewhere that referred to it by name
145
+ dirty_names = store.names_in(dirty_files) if dirty_files else set()
146
+ # ...and importers must be captured before delete_file drops the rows that point at them,
147
+ # otherwise their import edges would never be recomputed and would stay missing
148
+ if dirty_files:
149
+ dirty_files |= store.importers_of(dirty_files)
150
+
151
+ for r in sorted(removed):
152
+ store.delete_file(r)
153
+ for rel, *_ in to_parse:
154
+ if rel in old: # a new file has nothing to delete
155
+ store.delete_file(rel)
156
+ changed = 0
157
+ parsed_all = _parse_all([(data, lang, Path(rel).stem) for rel, _p, lang, data, _h in to_parse])
158
+ for (rel, _p, lang, data, h), (parsed, err) in zip(to_parse, parsed_all, strict=True):
159
+ if parsed is None:
160
+ print(f"codegraph: skipped {rel}: {err}", file=sys.stderr) # stdout is the MCP channel
161
+ continue
162
+ store.add_file(rel, lang, h, parsed, data.count(b"\n") + 1)
163
+ dirty_names |= {s.name for s in parsed.syms}
164
+ changed += 1
165
+ if changed % 200 == 0:
166
+ note(f"parsed {changed} files")
167
+
168
+ dirty_names -= {None}
169
+ resolve_stats: dict = {}
170
+ rank_stats: dict = {}
171
+ link_stats: dict = {}
172
+ if changed or removed or force or store.migrated:
173
+ resolve_stats = store.resolve(full=force or not dirty_names,
174
+ dirty_files=dirty_files,
175
+ dirty_names=dirty_names)
176
+ store.set_meta("parser_version", str(PARSER_VERSION))
177
+ store.set_meta("schema_version", str(SCHEMA_VERSION))
178
+ rank_stats = recompute_rank(store)
179
+ link_stats.update(links.link_static(store.db))
180
+ link_stats["communities"] = community.detect(store.db)
181
+ link_stats["embed"] = embed.update(store, note)
182
+ link_stats["cochange"] = links.link_cochange(store, root, force) # -1: unchanged, 0: no git
183
+ store.commit()
184
+ db = store.db
185
+ n_sym = db.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
186
+ n_edge = db.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
187
+ store.close()
188
+ return {"files": len(seen), "changed": changed, "removed": len(removed),
189
+ "symbols": n_sym, "edges": n_edge,
190
+ "resolve": resolve_stats, "rank": rank_stats, "links": link_stats}
codegraph/install.py ADDED
@@ -0,0 +1,219 @@
1
+ """`codegraph install` and `codegraph hook`: wire the graph into coding assistants and into git.
2
+
3
+ Everything written here is idempotent and reversible: instruction text lives between markers inside
4
+ files the user may also edit by hand, JSON configs are merged key by key, and git hooks are appended to
5
+ rather than replaced.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import stat
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ START, END = "<!-- codegraph:start -->", "<!-- codegraph:end -->"
16
+ SH_START, SH_END = "# >>> codegraph >>>", "# <<< codegraph <<<"
17
+ GIT_HOOKS = ("post-commit", "post-checkout", "post-merge", "post-rewrite")
18
+ NUDGE_MARK = "codegraph-nudge" # lets us find our own hook entry again
19
+ PLATFORMS = ("claude", "cursor", "gemini", "agents")
20
+
21
+ GUIDE = """\
22
+ ## Code navigation: use codegraph before reading files
23
+
24
+ This repository is indexed by codegraph (symbols, call graph, imports, docs, SQL). Ask the graph instead
25
+ of reading whole files or grepping:
26
+
27
+ 1. `repo_map` for an overview (pass `focus=` to personalise it), `search` to find a symbol by name or meaning.
28
+ 2. `context <symbol>` for its source, what it uses and who uses it, in one reply.
29
+ 3. `impact <symbol>` before changing anything; `review` before opening a PR (what the diff touches,
30
+ blast radius, untested changes).
31
+ 4. `callers` / `callees` / `related` / `deps` to walk the graph.
32
+
33
+ Edges tagged `[inferred]` or `[ambiguous]` (or `?` in `impact`) are name-based guesses, so treat them as
34
+ upper bounds and confirm by reading the code. Read files directly only for exact surrounding code.
35
+
36
+ No MCP server connected? The same tools run from the shell: `codegraph context <symbol>`,
37
+ `codegraph impact <symbol>`, `codegraph review`. Exit code 3 means the name is ambiguous, 4 that it was
38
+ not found.
39
+ """
40
+
41
+ SKILL = f"""---
42
+ name: codegraph
43
+ description: Navigate this codebase through its code graph (symbols, callers, impact, docs, SQL) instead of reading whole files. Use before searching or reading code, before editing a function, and before opening a PR.
44
+ ---
45
+
46
+ {GUIDE}"""
47
+
48
+ NUDGE = ("codegraph is available for this repo: try repo_map / search / context before grepping or "
49
+ "reading whole files (CLI: codegraph search <query>).")
50
+
51
+
52
+ def _write(path: Path, text: str, report: list[str], what: str):
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ if path.exists() and path.read_text() == text:
55
+ report.append(f"unchanged {what}")
56
+ return
57
+ verb = "updated" if path.exists() else "created"
58
+ path.write_text(text)
59
+ report.append(f"{verb:<10} {what}")
60
+
61
+
62
+ def _block(text: str) -> str:
63
+ return f"{START}\n{text.rstrip()}\n{END}\n"
64
+
65
+
66
+ def _upsert_block(path: Path, text: str, report: list[str], what: str):
67
+ """Put `text` between markers in a file that may hold other content; replace it if already there."""
68
+ block = _block(text)
69
+ cur = path.read_text() if path.exists() else ""
70
+ if START in cur and END in cur:
71
+ a, b = cur.index(START), cur.index(END) + len(END)
72
+ new = cur[:a] + block.rstrip("\n") + cur[b:]
73
+ else:
74
+ new = cur + ("\n" if cur and not cur.endswith("\n\n") else "") + block
75
+ _write(path, new, report, what)
76
+
77
+
78
+ def _load_json(path: Path) -> dict:
79
+ if not path.exists():
80
+ return {}
81
+ try:
82
+ data = json.loads(path.read_text() or "{}")
83
+ except json.JSONDecodeError as e:
84
+ raise SystemExit(f"codegraph: {path} is not valid JSON ({e}); fix it or move it aside, then re-run") from e
85
+ if not isinstance(data, dict):
86
+ raise SystemExit(f"codegraph: {path} must hold a JSON object")
87
+ return data
88
+
89
+
90
+ def _merge_mcp(path: Path, report: list[str], what: str):
91
+ data = _load_json(path)
92
+ servers = data.setdefault("mcpServers", {})
93
+ # relative root: MCP clients start servers in the project directory, so the config stays portable
94
+ servers["codegraph"] = {"command": "codegraph", "args": ["-r", ".", "serve"]}
95
+ _write(path, json.dumps(data, indent=2) + "\n", report, what)
96
+
97
+
98
+ def _claude_hook(root: Path, report: list[str]):
99
+ path = root / ".claude" / "settings.json"
100
+ data = _load_json(path)
101
+ payload = json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": NUDGE}})
102
+ entry = {"matcher": "Glob|Grep", "hooks": [{"type": "command", "command": f"echo '{payload}' # {NUDGE_MARK}"}]}
103
+ pre = data.setdefault("hooks", {}).setdefault("PreToolUse", [])
104
+ pre[:] = [e for e in pre if NUDGE_MARK not in json.dumps(e)] + [entry]
105
+ _write(path, json.dumps(data, indent=2) + "\n", report, ".claude/settings.json (Glob|Grep hint)")
106
+
107
+
108
+ def install(root: str | Path, platform: str = "all", hooks: bool = True) -> list[str]:
109
+ root = Path(root).resolve()
110
+ chosen = PLATFORMS if platform == "all" else (platform,)
111
+ unknown = [p for p in chosen if p not in PLATFORMS]
112
+ if unknown:
113
+ raise SystemExit(f"codegraph: unknown platform {unknown[0]!r}; choose from {', '.join(PLATFORMS)}, all")
114
+ report: list[str] = []
115
+ for p in chosen:
116
+ if p == "claude":
117
+ _write(root / ".claude" / "skills" / "codegraph" / "SKILL.md", SKILL, report,
118
+ ".claude/skills/codegraph/SKILL.md")
119
+ _merge_mcp(root / ".mcp.json", report, ".mcp.json (MCP server)")
120
+ _upsert_block(root / "CLAUDE.md", GUIDE, report, "CLAUDE.md (guidance block)")
121
+ if hooks:
122
+ _claude_hook(root, report)
123
+ elif p == "cursor":
124
+ rule = ("---\ndescription: Navigate the codebase with codegraph before reading files\n"
125
+ f"alwaysApply: true\n---\n\n{GUIDE}")
126
+ _write(root / ".cursor" / "rules" / "codegraph.mdc", rule, report, ".cursor/rules/codegraph.mdc")
127
+ _merge_mcp(root / ".cursor" / "mcp.json", report, ".cursor/mcp.json (MCP server)")
128
+ elif p == "gemini":
129
+ _upsert_block(root / "GEMINI.md", GUIDE, report, "GEMINI.md (guidance block)")
130
+ _merge_mcp(root / ".gemini" / "settings.json", report, ".gemini/settings.json (MCP server)")
131
+ elif p == "agents": # Codex, opencode, Aider (via --read), Copilot and others read AGENTS.md
132
+ _upsert_block(root / "AGENTS.md", GUIDE, report, "AGENTS.md (guidance block)")
133
+ return report
134
+
135
+
136
+ def uninstall(root: str | Path) -> list[str]:
137
+ """Remove what `install` wrote. Files that held only our block are deleted."""
138
+ root = Path(root).resolve()
139
+ report: list[str] = []
140
+ for rel in ("CLAUDE.md", "GEMINI.md", "AGENTS.md"):
141
+ path = root / rel
142
+ if path.exists() and START in (cur := path.read_text()) and END in cur:
143
+ rest = (cur[:cur.index(START)] + cur[cur.index(END) + len(END):]).strip()
144
+ if rest:
145
+ path.write_text(rest + "\n")
146
+ else:
147
+ path.unlink()
148
+ report.append(f"cleaned {rel}")
149
+ for rel in (".claude/skills/codegraph/SKILL.md", ".cursor/rules/codegraph.mdc"):
150
+ if (root / rel).exists():
151
+ (root / rel).unlink()
152
+ report.append(f"removed {rel}")
153
+ for rel in (".mcp.json", ".cursor/mcp.json", ".gemini/settings.json"):
154
+ path = root / rel
155
+ if path.exists() and "codegraph" in _load_json(path).get("mcpServers", {}):
156
+ data = _load_json(path)
157
+ del data["mcpServers"]["codegraph"]
158
+ path.write_text(json.dumps(data, indent=2) + "\n")
159
+ report.append(f"cleaned {rel}")
160
+ path = root / ".claude" / "settings.json"
161
+ if path.exists() and NUDGE_MARK in path.read_text():
162
+ data = _load_json(path)
163
+ pre = data.get("hooks", {}).get("PreToolUse", [])
164
+ pre[:] = [e for e in pre if NUDGE_MARK not in json.dumps(e)]
165
+ path.write_text(json.dumps(data, indent=2) + "\n")
166
+ report.append("cleaned .claude/settings.json")
167
+ return report or ["nothing to remove"]
168
+
169
+
170
+ # ---- git hooks ----
171
+
172
+ def _hooks_dir(root: Path) -> Path:
173
+ p = subprocess.run(["git", "-C", str(root), "rev-parse", "--git-path", "hooks"],
174
+ capture_output=True, text=True)
175
+ if p.returncode != 0:
176
+ raise SystemExit("codegraph: not a git repository")
177
+ d = Path(p.stdout.strip())
178
+ return d if d.is_absolute() else root / d
179
+
180
+
181
+ def _hook_block() -> str:
182
+ # backgrounded: a commit or checkout must never wait for, or fail because of, the indexer
183
+ return (f"{SH_START}\ncommand -v codegraph >/dev/null 2>&1 && "
184
+ f'(codegraph -r "$(git rev-parse --show-toplevel)" index >/dev/null 2>&1 &)\n{SH_END}\n')
185
+
186
+
187
+ def hook_install(root: str | Path) -> list[str]:
188
+ root = Path(root).resolve()
189
+ d = _hooks_dir(root)
190
+ d.mkdir(parents=True, exist_ok=True)
191
+ report = []
192
+ for name in GIT_HOOKS:
193
+ path = d / name
194
+ cur = path.read_text() if path.exists() else "#!/bin/sh\n"
195
+ if SH_START in cur:
196
+ a, b = cur.index(SH_START), cur.index(SH_END) + len(SH_END)
197
+ new = cur[:a] + _hook_block().rstrip("\n") + cur[b:]
198
+ else:
199
+ new = cur + ("" if cur.endswith("\n") else "\n") + _hook_block()
200
+ path.write_text(new)
201
+ path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
202
+ report.append(f"installed {os.path.relpath(path, root)}")
203
+ return report
204
+
205
+
206
+ def hook_uninstall(root: str | Path) -> list[str]:
207
+ root = Path(root).resolve()
208
+ d, report = _hooks_dir(root), []
209
+ for name in GIT_HOOKS:
210
+ path = d / name
211
+ if not path.exists() or SH_START not in (cur := path.read_text()):
212
+ continue
213
+ rest = (cur[:cur.index(SH_START)] + cur[cur.index(SH_END) + len(SH_END):]).strip()
214
+ if rest in ("", "#!/bin/sh"):
215
+ path.unlink()
216
+ else:
217
+ path.write_text(rest + "\n")
218
+ report.append(f"removed {os.path.relpath(path, root)}")
219
+ return report or ["no codegraph hooks found"]
codegraph/keys.py ADDED
@@ -0,0 +1,94 @@
1
+ """String-keyed hidden links: routes, event names and config keys.
2
+
3
+ Two symbols that mention the same string literal in a recognisable role (a route path registered
4
+ here and requested there, an event emitted here and listened to there) are related even though
5
+ no call edge connects them. Recognition is table-driven: add a `Pattern` row to support a new
6
+ framework. The parser feeds this module the callee text and the first string argument of every
7
+ call; resolution (matching roles across files) lives in links.py.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+
14
+ PY = frozenset({"python"})
15
+ JS = frozenset({"javascript", "typescript", "tsx"})
16
+ ANY = PY | JS
17
+
18
+ _HTTP = "get|post|put|patch|delete|head|options"
19
+ _STRING = re.compile(r"""^[rbfuRBFU]{0,2}(\"\"\"|'''|"|'|`)(.*)\1$""", re.DOTALL)
20
+ # events every Node/DOM program emits and listens to: linking on them would connect unrelated code
21
+ COMMON_EVENTS = frozenset(
22
+ "error data end close exit open message connect finish drain readable line abort timeout "
23
+ "click change input submit keydown keyup load resize sigint sigterm uncaughtexception "
24
+ "unhandledrejection".split())
25
+
26
+
27
+ def _route(k: str) -> bool:
28
+ return k.startswith("/") and len(k) > 1 and " " not in k
29
+
30
+
31
+ def _event(k: str) -> bool:
32
+ return len(k) >= 3 and k.lower() not in COMMON_EVENTS and re.fullmatch(r"[\w.:/@-]+", k) is not None
33
+
34
+
35
+ def _config(k: str) -> bool:
36
+ return len(k) >= 3 and re.fullmatch(r"[\w.:-]+", k) is not None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Pattern:
41
+ langs: frozenset
42
+ callee: re.Pattern # matched against the whole callee text, e.g. `app.get`
43
+ ns: str # namespace of the key: keys only match inside one namespace
44
+ role: str # see links.KEY_ROLES for how roles pair up
45
+ valid: object # str -> bool, rejects keys that are too generic to mean anything
46
+ decorator: bool | None = None # True: only as a decorator, False: only as a plain call
47
+
48
+
49
+ def _p(langs, callee, ns, role, valid, decorator=None) -> Pattern:
50
+ return Pattern(langs, re.compile(callee), ns, role, valid, decorator)
51
+
52
+
53
+ # Add rows here to teach codegraph a new framework. Roles per namespace: see links.KEY_ROLES.
54
+ PATTERNS: tuple[Pattern, ...] = (
55
+ # routes: registered (provides) vs requested (uses)
56
+ _p(PY, rf"^\w+\.({_HTTP}|route|websocket|api_route)$", "route", "provides", _route, decorator=True),
57
+ _p(PY, rf"^(?:\w+\.)*(client|requests|httpx|session|http|api)\.({_HTTP})$", "route", "uses", _route,
58
+ decorator=False),
59
+ _p(JS, rf"^(app|router|server|\w+Router)\.({_HTTP}|all)$", "route", "provides", _route),
60
+ _p(JS, rf"^(fetch|axios|ky|\$http)$|^(?:\w+\.)*(axios|client|http|api|request|supertest)\.({_HTTP})$",
61
+ "route", "uses", _route),
62
+ # events: emitted vs listened to
63
+ _p(ANY, r"(?:^|\.)(emit|dispatch|dispatchEvent|trigger|publish)$", "event", "emits", _event),
64
+ _p(ANY, r"(?:^|\.)(on|once|addListener|addEventListener|subscribe|prependListener)$",
65
+ "event", "listens", _event),
66
+ # config keys: everything that reads the same key is related
67
+ _p(PY, r"(?:^|\.)(getenv|environ\.get|environ\.setdefault)$", "config", "reads", _config),
68
+ _p(ANY, r"^(?:self\.|this\.)?(config|settings|cfg|conf|env)\.(get|getint|getboolean|require)$",
69
+ "config", "reads", _config),
70
+ )
71
+
72
+
73
+ def string_value(text: str) -> str | None:
74
+ """The contents of a plain string literal, or None for anything computed or interpolated."""
75
+ m = _STRING.match(text.strip())
76
+ if not m:
77
+ return None
78
+ body = m.group(2)
79
+ if m.group(1) == "`" and "${" in body:
80
+ return None
81
+ if text.lstrip()[:1] in "fF" and "{" in body: # f-string: not a constant
82
+ return None
83
+ return body
84
+
85
+
86
+ def match(lang: str, callee: str, key: str, decorator: bool) -> list[tuple[str, str, str]]:
87
+ """Every (namespace, key, role) a call `callee("key", ...)` contributes."""
88
+ out = []
89
+ for p in PATTERNS:
90
+ if lang not in p.langs or (p.decorator is not None and p.decorator != decorator):
91
+ continue
92
+ if p.callee.search(callee) and p.valid(key):
93
+ out.append((p.ns, key, p.role))
94
+ return out