codegraph-brain 0.6.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 (78) hide show
  1. cgis/__init__.py +10 -0
  2. cgis/__main__.py +16 -0
  3. cgis/api/.gitkeep +0 -0
  4. cgis/api/__init__.py +1 -0
  5. cgis/api/mcp_server.py +550 -0
  6. cgis/cli.py +1516 -0
  7. cgis/core/.gitkeep +0 -0
  8. cgis/core/models.py +140 -0
  9. cgis/extractors/.gitkeep +0 -0
  10. cgis/extractors/_python_ast.py +194 -0
  11. cgis/extractors/_python_classes.py +126 -0
  12. cgis/extractors/_python_functions.py +312 -0
  13. cgis/extractors/_python_imports.py +188 -0
  14. cgis/extractors/_python_types.py +84 -0
  15. cgis/extractors/base.py +42 -0
  16. cgis/extractors/python_extractor.py +235 -0
  17. cgis/extractors/typescript_extractor.py +310 -0
  18. cgis/guardian/__init__.py +1 -0
  19. cgis/guardian/bench.py +199 -0
  20. cgis/guardian/chunked.py +240 -0
  21. cgis/guardian/chunker.py +148 -0
  22. cgis/guardian/collector.py +309 -0
  23. cgis/guardian/core.py +120 -0
  24. cgis/guardian/diff_index.py +151 -0
  25. cgis/guardian/findings.py +70 -0
  26. cgis/guardian/github_poster.py +133 -0
  27. cgis/guardian/metrics.py +108 -0
  28. cgis/guardian/prompts.py +217 -0
  29. cgis/guardian/providers/__init__.py +1 -0
  30. cgis/guardian/providers/base.py +112 -0
  31. cgis/guardian/providers/gemini.py +83 -0
  32. cgis/guardian/providers/mistral.py +83 -0
  33. cgis/guardian/providers/ollama.py +99 -0
  34. cgis/guardian/recording.py +82 -0
  35. cgis/guardian/render.py +107 -0
  36. cgis/guardian/runner.py +295 -0
  37. cgis/guardian/skeptic.py +208 -0
  38. cgis/pipeline.py +252 -0
  39. cgis/py.typed +0 -0
  40. cgis/query/analysis/__init__.py +0 -0
  41. cgis/query/analysis/analyzer.py +241 -0
  42. cgis/query/analysis/anomaly.py +34 -0
  43. cgis/query/analysis/cohesion.py +277 -0
  44. cgis/query/analysis/health.py +128 -0
  45. cgis/query/analysis/suggest_service.py +221 -0
  46. cgis/query/context/__init__.py +0 -0
  47. cgis/query/context/audit.py +134 -0
  48. cgis/query/context/context_service.py +129 -0
  49. cgis/query/context/prompt.py +184 -0
  50. cgis/query/context/snippet.py +96 -0
  51. cgis/query/drift/__init__.py +0 -0
  52. cgis/query/drift/_scc.py +90 -0
  53. cgis/query/drift/drift.py +867 -0
  54. cgis/query/drift/drift_service.py +217 -0
  55. cgis/query/drift/fingerprint.py +255 -0
  56. cgis/query/drift/fractal.py +292 -0
  57. cgis/query/drift/ontology_init.py +470 -0
  58. cgis/query/drift/quotient.py +75 -0
  59. cgis/query/drift/triads.py +234 -0
  60. cgis/query/engine.py +170 -0
  61. cgis/query/fqn.py +65 -0
  62. cgis/query/render/__init__.py +0 -0
  63. cgis/query/render/graph_json.py +42 -0
  64. cgis/query/render/mermaid.py +235 -0
  65. cgis/query/render/metrics.py +346 -0
  66. cgis/resolver/.gitkeep +0 -0
  67. cgis/resolver/__init__.py +1 -0
  68. cgis/resolver/engine.py +145 -0
  69. cgis/resolver/indices.py +184 -0
  70. cgis/resolver/symbols.py +179 -0
  71. cgis/resolver/uplift.py +263 -0
  72. cgis/storage/.gitkeep +0 -0
  73. cgis/storage/sqlite_store.py +589 -0
  74. codegraph_brain-0.6.0.dist-info/METADATA +240 -0
  75. codegraph_brain-0.6.0.dist-info/RECORD +78 -0
  76. codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
  77. codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
  78. codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,208 @@
1
+ """Skeptic pass: one judgement per finding on two axes (spec §5, amended by #246).
2
+
3
+ Each finding gets its own LLM call carrying only its file's hunks, and comes
4
+ back with a ``verdict`` (is the claim true) and an ``impact_score`` 0-10 (does
5
+ it matter). The batch call this replaced put every finding in one window and
6
+ addressed verdicts by list index; it could neither rank nor survive a partial
7
+ failure.
8
+ """
9
+
10
+ import asyncio
11
+ from collections.abc import Iterable
12
+ from typing import Literal
13
+
14
+ import structlog
15
+ from pydantic import BaseModel, Field
16
+
17
+ from cgis.guardian.diff_index import split_diff_by_file
18
+ from cgis.guardian.findings import Finding, extract_json
19
+ from cgis.guardian.providers.base import BaseProvider
20
+
21
+ log = structlog.getLogger(__name__)
22
+
23
+ _UNCERTAIN_MULTIPLIER = 0.9 # an 'uncertain' verdict discounts confidence as a
24
+ # ranking signal only — it NEVER refutes. The recall-lean finder emits genuine
25
+ # low-confidence findings on purpose; only an explicit 'refuted' verdict drops one.
26
+
27
+
28
+ class FindingJudgement(BaseModel, frozen=True):
29
+ """One skeptic ruling on one finding, on two orthogonal axes (#246 spec §3.1).
30
+
31
+ ``verdict`` answers "is this claim true" and only an explicit 'refuted'
32
+ drops the finding. ``impact_score`` answers "does it matter" and only hides
33
+ below a threshold. There is no index: a judgement belongs to the call that
34
+ produced it, which is why the batch API's index-mapping failure modes
35
+ (out-of-range, duplicate) cannot occur here.
36
+ """
37
+
38
+ verdict: Literal["confirmed", "refuted", "uncertain"]
39
+ impact_score: int = Field(ge=0, le=10)
40
+ rationale: str
41
+
42
+
43
+ # Confirm-by-default stance. The original refute-by-default wording over-killed
44
+ # in benchmarks: a gemini-3.5-flash skeptic refuted 7/7 finder findings,
45
+ # including 2 ground-truth matches on PR 122 (gate allows at most 1 lost match).
46
+ # The finder is now recall-lean (no confidence gate, no cap), so it surfaces
47
+ # genuine low-confidence findings BY DESIGN and leans on this pass for precision —
48
+ # the skeptic must cut hallucinations without re-introducing the gate it removed.
49
+ SKEPTIC_SYSTEM_PROMPT = (
50
+ "You are a skeptical senior reviewer double-checking another reviewer's findings. "
51
+ "That reviewer optimises for RECALL: it deliberately surfaces plausible, sometimes "
52
+ "low-confidence candidates and relies on you to remove only the ones that are wrong. "
53
+ "For each finding, verify the quoted evidence against the diff and judge whether the "
54
+ "claimed defect is real. Refute a finding ONLY when you can point to concrete evidence "
55
+ "that it is wrong: the quoted code does not appear in the diff, the case is already "
56
+ "handled, or the claim misreads what the code does. Do NOT refute a finding merely for "
57
+ "being low-confidence, speculative, or a judgement call — if it is plausible and you "
58
+ "cannot disprove it, mark it 'confirmed' or 'uncertain' (both are kept), never 'refuted'."
59
+ )
60
+
61
+
62
+ IMPACT_RUBRIC = """
63
+ Also rate how much this finding MATTERS, independent of whether it is true,
64
+ as impact_score 0-10:
65
+
66
+ - 0-2 true but not actionable: style, taste, "consider X for explicitness",
67
+ or restating something the project's tooling already enforces.
68
+ If `ruff`, `ruff format` or `mypy --strict` would catch it, score <= 2:
69
+ those run as mandatory gates in this repo, so such issues are already
70
+ covered.
71
+ - 3-5 a minor real issue: local clarity or robustness, no behaviour change.
72
+ - 6-8 a real defect with a concrete failure path in this diff.
73
+ - 9-10 a broken contract, a security hole, or data loss.
74
+
75
+ A true finding can score 0. Scoring is NOT a second chance to refute: judge
76
+ truth and importance independently."""
77
+
78
+
79
+ def build_judgement_prompt(finding: Finding, hunks: str) -> str:
80
+ """Assemble the user prompt judging ONE finding against its own file's hunks.
81
+
82
+ The finder's ``confidence`` and ``severity`` are deliberately omitted: both
83
+ are its own guess at what this pass re-derives independently, and showing
84
+ them anchors the judge on the claim it is meant to check (#246 §3.3).
85
+ """
86
+ location = f"{finding.file}:{finding.line}" if finding.line is not None else finding.file
87
+ return f"""Another reviewer claims this diff contains a defect.
88
+
89
+ ### THE CLAIM
90
+ Location: {location}
91
+ Title: {finding.title}
92
+ Quoted code: {finding.evidence}
93
+ Problem: {finding.problem}
94
+ Proposed fix: {finding.fix}
95
+
96
+ ### THE DIFF HUNKS FOR THAT FILE
97
+ {hunks or "(no hunks available for this file)"}
98
+
99
+ ### HOW TO JUDGE
100
+ Verify the quoted code against the hunks above. If the claim depends on code
101
+ that is NOT in these hunks, you cannot check it: that is grounds for
102
+ 'uncertain', never for 'refuted'.
103
+ {IMPACT_RUBRIC}
104
+
105
+ ### OUTPUT FORMAT
106
+ Return ONLY a JSON object:
107
+ {{"verdict": "confirmed|refuted|uncertain", "impact_score": 0, "rationale": "one sentence"}}"""
108
+
109
+
110
+ DEFAULT_SKEPTIC_CONCURRENCY = 3
111
+ # Bounded because provider rate limits are the binding constraint, not local CPU:
112
+ # mistral's free tier caps tokens per MINUTE, and a local ollama skeptic
113
+ # serialises on one model instance anyway.
114
+
115
+
116
+ async def judge_finding(
117
+ provider: BaseProvider, finding: Finding, hunks: str
118
+ ) -> FindingJudgement | None:
119
+ """Judge one finding; None means this call failed (#246 §3.2).
120
+
121
+ Every failure mode — transport error, rate limit, unparseable output —
122
+ collapses to None so the caller keeps the finding unruled and visible. A
123
+ skeptic that cannot answer must never be able to silence a finding.
124
+ """
125
+ try:
126
+ raw = await provider.generate_structured(
127
+ SKEPTIC_SYSTEM_PROMPT, build_judgement_prompt(finding, hunks), FindingJudgement
128
+ )
129
+ return FindingJudgement.model_validate_json(extract_json(raw))
130
+ except Exception:
131
+ log.warning("Skeptic judgement failed; finding stays unruled.", file=finding.file)
132
+ return None
133
+
134
+
135
+ def skeptic_status_for(judged: int, total: int) -> Literal["ok", "partial", "failed"]:
136
+ """Map judged/total onto the reported skeptic status (#246 §3.4).
137
+
138
+ Public and shared: the chunked orchestrator reports the same statuses, and
139
+ two copies of this mapping would be free to drift apart.
140
+ """
141
+ if judged == 0:
142
+ return "failed"
143
+ return "ok" if judged == total else "partial"
144
+
145
+
146
+ async def judge_all(
147
+ provider: BaseProvider,
148
+ findings: list[Finding],
149
+ diff: str,
150
+ concurrency: int = DEFAULT_SKEPTIC_CONCURRENCY,
151
+ ) -> list[FindingJudgement | None]:
152
+ """Judge every finding concurrently, each against its own file's hunks.
153
+
154
+ Returns one entry per finding, positionally aligned, with None where that
155
+ finding's call failed — so a single failure costs one verdict instead of the
156
+ whole pass, which is what the batch call it replaces did.
157
+ """
158
+ blocks = split_diff_by_file(diff)
159
+ semaphore = asyncio.Semaphore(max(1, concurrency))
160
+
161
+ async def _one(finding: Finding) -> FindingJudgement | None:
162
+ async with semaphore:
163
+ return await judge_finding(provider, finding, blocks.get(finding.file, ""))
164
+
165
+ return list(await asyncio.gather(*(_one(f) for f in findings)))
166
+
167
+
168
+ def apply_judgements(
169
+ findings: list[Finding], judgements: list[FindingJudgement | None]
170
+ ) -> list[Finding]:
171
+ """Merge per-finding judgements into new frozen copies, positionally (#246 §3.2).
172
+
173
+ ``judgements[i]`` rules on ``findings[i]``; a ``None`` means that call failed
174
+ and the finding stays unruled — absence of a judgement is not a refutation.
175
+ 'uncertain' discounts confidence x0.9 as a ranking signal only, identical to
176
+ the batch merge it replaces.
177
+ """
178
+ merged: list[Finding] = []
179
+ for finding, judgement in zip(findings, judgements, strict=True):
180
+ if judgement is None:
181
+ merged.append(finding)
182
+ continue
183
+ update: dict[str, object] = {
184
+ "verdict": judgement.verdict,
185
+ "skeptic_note": judgement.rationale,
186
+ "impact_score": judgement.impact_score,
187
+ }
188
+ if judgement.verdict == "uncertain":
189
+ update["confidence"] = round(finding.confidence * _UNCERTAIN_MULTIPLIER)
190
+ merged.append(finding.model_copy(update=update))
191
+ return merged
192
+
193
+
194
+ def visible_findings(findings: Iterable[Finding], threshold: int = 0) -> list[Finding]:
195
+ """Findings that appear in the rendered report (#246 §3.2).
196
+
197
+ Hidden: anything refuted, and anything the skeptic scored below
198
+ ``threshold``. An unjudged finding (``impact_score is None``) has no score to
199
+ compare and is always shown — a failed judgement call must never silence a
200
+ finding. Hidden findings stay in ``ReviewResult.findings`` so metrics and the
201
+ benchmark still see what was cut.
202
+ """
203
+ return [
204
+ f
205
+ for f in findings
206
+ if f.verdict != "refuted"
207
+ and not (f.impact_score is not None and f.impact_score < threshold)
208
+ ]
cgis/pipeline.py ADDED
@@ -0,0 +1,252 @@
1
+ """Implements Pipeline to orcestrate code traversal."""
2
+
3
+ import hashlib
4
+ import re
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ import structlog
10
+ from rich.console import Console
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn
12
+
13
+ from cgis.core.models import Edge, Node
14
+ from cgis.extractors.base import BaseExtractor
15
+ from cgis.resolver.engine import ResolverEngine
16
+ from cgis.resolver.uplift import SemanticUpliftEngine
17
+
18
+ if TYPE_CHECKING:
19
+ from cgis.storage.sqlite_store import SQLiteStore
20
+
21
+ logger = structlog.getLogger(__name__)
22
+
23
+
24
+ class IngestionPipeline:
25
+ """Orchestrates the full Extract → Resolve → Store pipeline over a source tree."""
26
+
27
+ _extractors: Mapping[str, BaseExtractor]
28
+ _domains_config: str | None
29
+
30
+ def __init__(
31
+ self,
32
+ extractors: Mapping[str, BaseExtractor],
33
+ domains_config: str | None = None,
34
+ ) -> None:
35
+ """
36
+ Args:
37
+ extractors: Map of file extensions to their respective extractors.
38
+ e.g., {".py": PythonExtractor()}
39
+ domains_config: Optional path to a domains.yaml file. When provided,
40
+ the SemanticUpliftEngine runs after resolution.
41
+ """
42
+ self._extractors = extractors
43
+ self._domains_config = domains_config
44
+ self._excluded = {"venv", ".venv", "__pycache__", "node_modules", "build", "dist"}
45
+
46
+ @staticmethod
47
+ def _compute_hash(content: str) -> str:
48
+ """Return the MD5 hex digest of the given source content string."""
49
+ return hashlib.md5(content.encode("utf-8"), usedforsecurity=False).hexdigest()
50
+
51
+ def run(
52
+ self,
53
+ repo_path: str,
54
+ store: "SQLiteStore | None" = None,
55
+ ) -> tuple[list[Node], list[Edge], list[Edge]]:
56
+ """
57
+ The main pipeline execution: Walk -> Extract -> Resolve.
58
+
59
+ When `store` is provided the pipeline runs in incremental mode:
60
+ unchanged files (same MD5) are skipped and their nodes are loaded
61
+ from the store for the resolver. Only changed/new files are
62
+ re-extracted and persisted. Stale files (removed from disk) are
63
+ cleaned up automatically.
64
+ """
65
+ all_nodes: list[Node] = []
66
+ all_edges: list[Edge] = []
67
+ # file_path -> new hash, only for files that were re-extracted
68
+ changed_files: dict[str, str] = {}
69
+ found_file_paths: set[str] = set()
70
+
71
+ path = Path(repo_path)
72
+ if not path.exists():
73
+ msg = f"Path not found: {repo_path}"
74
+ raise FileNotFoundError(msg)
75
+ if not path.is_dir():
76
+ msg = f"Path is not a directory: {repo_path}"
77
+ raise NotADirectoryError(msg)
78
+
79
+ # Canonical workspace root: resolve symlinks + relative dots so that
80
+ # both `cgis ingest ./src` and `cgis ingest /abs/path/src` produce
81
+ # identical file_paths and FQNs in the database.
82
+ workspace_root = path.resolve()
83
+
84
+ with Progress(
85
+ SpinnerColumn(),
86
+ TextColumn("[progress.description]{task.description}"),
87
+ transient=True,
88
+ console=Console(stderr=True),
89
+ ) as progress:
90
+ # Task 1: Extraction
91
+ extract_task = progress.add_task(description="Extracting code entities...", total=None)
92
+
93
+ for root, dirs, files in workspace_root.walk():
94
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in self._excluded]
95
+ for file in files:
96
+ extractor = self._get_extractor(file)
97
+ if not extractor:
98
+ continue
99
+
100
+ full_path = root / file
101
+ try:
102
+ # Resolve symlinks before relativising so that a link
103
+ # pointing outside the workspace is caught and skipped.
104
+ rel_path_str = full_path.resolve().relative_to(workspace_root).as_posix()
105
+ except ValueError:
106
+ logger.warning("File outside workspace root, skipping", file=str(full_path))
107
+ continue
108
+ found_file_paths.add(rel_path_str)
109
+
110
+ self._process_file(
111
+ full_path,
112
+ rel_path_str,
113
+ extractor,
114
+ store,
115
+ all_nodes,
116
+ all_edges,
117
+ changed_files,
118
+ )
119
+
120
+ progress.update(extract_task, advance=1)
121
+
122
+ # Incremental no-op short-circuit (#185): when nothing changed and
123
+ # nothing went stale, the persisted graph is already correct.
124
+ # Re-running the resolver + persistence + uplift would rebuild the
125
+ # whole graph from the DB for zero benefit, so skip them entirely.
126
+ if store is not None and self._is_noop_incremental(
127
+ store, changed_files, found_file_paths
128
+ ):
129
+ logger.info("No changes detected — skipping resolution and persistence.")
130
+ return all_nodes, all_edges, []
131
+
132
+ # Task 2: Resolution
133
+ resolve_task = progress.add_task(description="Resolving semantic links...", total=None)
134
+ logger.info("Starting resolution phase...")
135
+ resolver = ResolverEngine(all_nodes, all_edges)
136
+ resolved_edges, virtual_nodes = resolver.resolve()
137
+ all_nodes.extend(virtual_nodes)
138
+ progress.update(resolve_task, advance=1)
139
+ logger.info(
140
+ "Resolution complete.",
141
+ edges=len(resolved_edges),
142
+ virtual_nodes=len(virtual_nodes),
143
+ )
144
+
145
+ if store is not None:
146
+ self._persist_incremental(
147
+ store, all_nodes, resolved_edges, changed_files, found_file_paths, virtual_nodes
148
+ )
149
+ logger.info("Running semantic uplift...")
150
+ SemanticUpliftEngine(store, self._domains_config).execute_uplift()
151
+ logger.info("Semantic uplift complete.")
152
+
153
+ return all_nodes, all_edges, resolved_edges
154
+
155
+ def _process_file(
156
+ self,
157
+ full_path: Path,
158
+ full_path_str: str,
159
+ extractor: BaseExtractor,
160
+ store: "SQLiteStore | None",
161
+ all_nodes: list[Node],
162
+ all_edges: list[Edge],
163
+ changed_files: dict[str, str],
164
+ ) -> None:
165
+ """Extract nodes/edges from one file, applying hash-based skip when store is provided."""
166
+ try:
167
+ with full_path.open(encoding="utf-8") as f:
168
+ code = f.read()
169
+
170
+ if store is not None:
171
+ file_hash = self._compute_hash(code)
172
+ if store.get_file_hash(full_path_str) == file_hash:
173
+ all_nodes.extend(store.get_nodes_by_file(full_path_str))
174
+ return
175
+ changed_files[full_path_str] = file_hash
176
+
177
+ nodes, edges = extractor.parse(code, full_path_str)
178
+ if nodes:
179
+ logger.info("Parsed nodes from file", nodes=len(nodes), full_path=full_path_str)
180
+ all_nodes.extend(nodes)
181
+ all_edges.extend(edges)
182
+ except Exception as e:
183
+ logger.exception("Failed to parse file", full_path=full_path, error=str(e))
184
+
185
+ def _is_noop_incremental(
186
+ self, store: "SQLiteStore", changed_files: dict[str, str], found_file_paths: set[str]
187
+ ) -> bool:
188
+ """True when an incremental run can be skipped entirely.
189
+
190
+ Requires no re-extracted files and no stale files. A configured domains
191
+ ontology also disables the skip: ``domains.yaml`` can change independently
192
+ of the source tree, and the semantic uplift that applies it must re-run
193
+ to pick those changes up (it is not tracked in ``changed_files``).
194
+
195
+ ``changed_files`` / ``domains_config`` are checked before the
196
+ ``get_all_tracked_files`` DB query so it is only issued when it can
197
+ actually change the outcome.
198
+ """
199
+ if changed_files or self._domains_config is not None:
200
+ return False
201
+ stale_files = store.get_all_tracked_files() - found_file_paths
202
+ return not stale_files
203
+
204
+ def _persist_incremental(
205
+ self,
206
+ store: "SQLiteStore",
207
+ all_nodes: list[Node],
208
+ resolved_edges: list[Edge],
209
+ changed_files: dict[str, str],
210
+ found_file_paths: set[str],
211
+ virtual_nodes: list[Node] | None = None,
212
+ ) -> None:
213
+ """Persist only changed files and clean up stale ones in one transaction."""
214
+ nodes_by_file: dict[str, list[Node]] = {}
215
+ for node in all_nodes:
216
+ if node.file_path in changed_files:
217
+ nodes_by_file.setdefault(node.file_path, []).append(node)
218
+
219
+ # Map source node → file so structural edges (file_path=None) can be assigned
220
+ source_to_file: dict[str, str] = {
221
+ node.id: node.file_path for node in all_nodes if node.file_path in changed_files
222
+ }
223
+ edges_by_file: dict[str, list[Edge]] = {}
224
+ for edge in resolved_edges:
225
+ file_path = edge.file_path or source_to_file.get(edge.source)
226
+ if file_path and file_path in changed_files:
227
+ edges_by_file.setdefault(file_path, []).append(edge)
228
+
229
+ stale_files = store.get_all_tracked_files() - found_file_paths
230
+ store.save_incremental_batch(nodes_by_file, edges_by_file, changed_files, stale_files)
231
+
232
+ # Virtual nodes are upserted separately — never deleted — because in incremental
233
+ # mode only changed files' edges are re-resolved, so virtual_nodes is incomplete.
234
+ # Orphaned virtual nodes (no incoming edges) are harmless phantom data.
235
+ if virtual_nodes:
236
+ store.upsert_nodes(virtual_nodes)
237
+
238
+ for file_path in changed_files:
239
+ logger.info("Re-ingested changed file", file_path=file_path)
240
+ for stale_path in stale_files:
241
+ logger.info("Removed stale file from graph", file_path=stale_path)
242
+
243
+ _TEST_FILE_PATTERN = re.compile(r"\.(test|spec)\.(py|ts|tsx|js|jsx)$", re.IGNORECASE)
244
+
245
+ def _get_extractor(self, filename: str) -> BaseExtractor | None:
246
+ """Return the registered extractor for the given filename, or None."""
247
+ if self._TEST_FILE_PATTERN.search(filename):
248
+ return None
249
+ for ext, extractor in self._extractors.items():
250
+ if filename.endswith(ext):
251
+ return extractor
252
+ return None
cgis/py.typed ADDED
File without changes
File without changes