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.
- cgis/__init__.py +10 -0
- cgis/__main__.py +16 -0
- cgis/api/.gitkeep +0 -0
- cgis/api/__init__.py +1 -0
- cgis/api/mcp_server.py +550 -0
- cgis/cli.py +1516 -0
- cgis/core/.gitkeep +0 -0
- cgis/core/models.py +140 -0
- cgis/extractors/.gitkeep +0 -0
- cgis/extractors/_python_ast.py +194 -0
- cgis/extractors/_python_classes.py +126 -0
- cgis/extractors/_python_functions.py +312 -0
- cgis/extractors/_python_imports.py +188 -0
- cgis/extractors/_python_types.py +84 -0
- cgis/extractors/base.py +42 -0
- cgis/extractors/python_extractor.py +235 -0
- cgis/extractors/typescript_extractor.py +310 -0
- cgis/guardian/__init__.py +1 -0
- cgis/guardian/bench.py +199 -0
- cgis/guardian/chunked.py +240 -0
- cgis/guardian/chunker.py +148 -0
- cgis/guardian/collector.py +309 -0
- cgis/guardian/core.py +120 -0
- cgis/guardian/diff_index.py +151 -0
- cgis/guardian/findings.py +70 -0
- cgis/guardian/github_poster.py +133 -0
- cgis/guardian/metrics.py +108 -0
- cgis/guardian/prompts.py +217 -0
- cgis/guardian/providers/__init__.py +1 -0
- cgis/guardian/providers/base.py +112 -0
- cgis/guardian/providers/gemini.py +83 -0
- cgis/guardian/providers/mistral.py +83 -0
- cgis/guardian/providers/ollama.py +99 -0
- cgis/guardian/recording.py +82 -0
- cgis/guardian/render.py +107 -0
- cgis/guardian/runner.py +295 -0
- cgis/guardian/skeptic.py +208 -0
- cgis/pipeline.py +252 -0
- cgis/py.typed +0 -0
- cgis/query/analysis/__init__.py +0 -0
- cgis/query/analysis/analyzer.py +241 -0
- cgis/query/analysis/anomaly.py +34 -0
- cgis/query/analysis/cohesion.py +277 -0
- cgis/query/analysis/health.py +128 -0
- cgis/query/analysis/suggest_service.py +221 -0
- cgis/query/context/__init__.py +0 -0
- cgis/query/context/audit.py +134 -0
- cgis/query/context/context_service.py +129 -0
- cgis/query/context/prompt.py +184 -0
- cgis/query/context/snippet.py +96 -0
- cgis/query/drift/__init__.py +0 -0
- cgis/query/drift/_scc.py +90 -0
- cgis/query/drift/drift.py +867 -0
- cgis/query/drift/drift_service.py +217 -0
- cgis/query/drift/fingerprint.py +255 -0
- cgis/query/drift/fractal.py +292 -0
- cgis/query/drift/ontology_init.py +470 -0
- cgis/query/drift/quotient.py +75 -0
- cgis/query/drift/triads.py +234 -0
- cgis/query/engine.py +170 -0
- cgis/query/fqn.py +65 -0
- cgis/query/render/__init__.py +0 -0
- cgis/query/render/graph_json.py +42 -0
- cgis/query/render/mermaid.py +235 -0
- cgis/query/render/metrics.py +346 -0
- cgis/resolver/.gitkeep +0 -0
- cgis/resolver/__init__.py +1 -0
- cgis/resolver/engine.py +145 -0
- cgis/resolver/indices.py +184 -0
- cgis/resolver/symbols.py +179 -0
- cgis/resolver/uplift.py +263 -0
- cgis/storage/.gitkeep +0 -0
- cgis/storage/sqlite_store.py +589 -0
- codegraph_brain-0.6.0.dist-info/METADATA +240 -0
- codegraph_brain-0.6.0.dist-info/RECORD +78 -0
- codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
- codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
- codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Drift-analysis orchestration shared by the CLI and the MCP server."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from cgis.core.models import Node
|
|
9
|
+
from cgis.query.drift.drift import DomainConfig, DriftReport, DriftScorer, FitQuality
|
|
10
|
+
from cgis.query.drift.fingerprint import FingerprintExtractor
|
|
11
|
+
from cgis.query.drift.ontology_init import discover_domains
|
|
12
|
+
from cgis.query.drift.quotient import build_quotient
|
|
13
|
+
from cgis.storage.sqlite_store import SQLiteStore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class DriftAnalysis:
|
|
18
|
+
"""Full drift run: per-domain reports plus the quotient (k=1) layer."""
|
|
19
|
+
|
|
20
|
+
reports: list[DriftReport]
|
|
21
|
+
quotient: list[tuple[DomainConfig, DriftReport]]
|
|
22
|
+
any_critical: bool
|
|
23
|
+
# Candidate domain prefixes present in the graph but bound by no project_domain
|
|
24
|
+
# — code drift silently ignores (#177 coverage roll-up).
|
|
25
|
+
coverage: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_ALLOWED_PATTERN_SUFFIXES = {".yaml", ".yml"}
|
|
29
|
+
|
|
30
|
+
# Fit-quality bands (#177): residual ≤ _GOOD_RESIDUAL is a clean archetype match;
|
|
31
|
+
# residual > max_residual means no template fits (see _fit_quality).
|
|
32
|
+
_GOOD_RESIDUAL = 0.25
|
|
33
|
+
# Statuses whose fingerprint carries a real, fittable shape.
|
|
34
|
+
_FITTABLE_STATUSES = frozenset({"clean", "warning", "critical", "gate_failed"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fit_quality(
|
|
38
|
+
scorer: DriftScorer, report: DriftReport, profile: str, max_residual: float
|
|
39
|
+
) -> FitQuality | None:
|
|
40
|
+
"""Rank the alphabet against one domain's shape and band the closest fit (#177).
|
|
41
|
+
|
|
42
|
+
Ranking uses the canonical distance (``fit_templates``); the reported
|
|
43
|
+
residuals and band use the gate-FREE ``shape_residual`` so a gate breach
|
|
44
|
+
(e.g. a cycle) never masquerades as "no template fits". Returns None when
|
|
45
|
+
the alphabet declares no templates (a profiles-only config).
|
|
46
|
+
"""
|
|
47
|
+
fits = scorer.fit_templates(report.actual, profile)
|
|
48
|
+
if not fits:
|
|
49
|
+
return None
|
|
50
|
+
nearest_name = fits[0][0]
|
|
51
|
+
nearest_res = scorer.shape_residual(report.actual, profile, nearest_name)
|
|
52
|
+
if nearest_res is None:
|
|
53
|
+
# No trusted shape weight (v1 profile / empty census / fully unresolved) —
|
|
54
|
+
# "no signal to fit" is not a perfect match. Leave fit unset.
|
|
55
|
+
return None
|
|
56
|
+
runner_name = fits[1][0] if len(fits) > 1 else None
|
|
57
|
+
runner_res = scorer.shape_residual(report.actual, profile, runner_name) if runner_name else None
|
|
58
|
+
# Keep the good-band reachable even when --max-residual is set below it.
|
|
59
|
+
good_cut = min(_GOOD_RESIDUAL, max_residual)
|
|
60
|
+
band: Literal["good", "weak", "none"]
|
|
61
|
+
if nearest_res > max_residual:
|
|
62
|
+
band = "none"
|
|
63
|
+
elif nearest_res <= good_cut:
|
|
64
|
+
band = "good"
|
|
65
|
+
else:
|
|
66
|
+
band = "weak"
|
|
67
|
+
return FitQuality(
|
|
68
|
+
nearest_template=nearest_name,
|
|
69
|
+
nearest_residual=nearest_res,
|
|
70
|
+
runner_up_template=runner_name,
|
|
71
|
+
runner_up_residual=runner_res,
|
|
72
|
+
band=band,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _uncovered_prefixes(nodes: list[Node], domain_prefixes: list[str]) -> list[str]:
|
|
77
|
+
"""Discovered domain candidates that overlap no declared domain prefix (#177).
|
|
78
|
+
|
|
79
|
+
A candidate is "covered" when it shares a subtree with a declared domain
|
|
80
|
+
(either is an ancestor-or-equal of the other). What remains is graph code
|
|
81
|
+
no ``project_domain`` binds — exactly what drift silently skips.
|
|
82
|
+
"""
|
|
83
|
+
return sorted(
|
|
84
|
+
candidate
|
|
85
|
+
for candidate in discover_domains(nodes)
|
|
86
|
+
if not any(
|
|
87
|
+
candidate == d or candidate.startswith(f"{d}.") or d.startswith(f"{candidate}.")
|
|
88
|
+
for d in domain_prefixes
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _empty_note(store: SQLiteStore, fqn_prefix: str) -> str:
|
|
94
|
+
"""Return a 'matched 0 nodes' diagnostic with closest-prefix suggestions (spec §2.4).
|
|
95
|
+
|
|
96
|
+
Short-circuits on blank prefixes (no DB query). Tries the full prefix as a
|
|
97
|
+
dot-boundary suffix first, then its last segment; caps at 3 suggestions.
|
|
98
|
+
"""
|
|
99
|
+
base = f"fqn_prefix '{fqn_prefix}' matched 0 nodes"
|
|
100
|
+
if not fqn_prefix.strip():
|
|
101
|
+
return base
|
|
102
|
+
matches = store.find_nodes_by_suffix(fqn_prefix, limit=3)
|
|
103
|
+
if not matches:
|
|
104
|
+
last_segment = fqn_prefix.rsplit(".", maxsplit=1)[-1]
|
|
105
|
+
if last_segment.strip():
|
|
106
|
+
matches = store.find_nodes_by_suffix(last_segment, limit=3)
|
|
107
|
+
if not matches:
|
|
108
|
+
return base
|
|
109
|
+
ids = sorted(n.id for n in matches)[:3]
|
|
110
|
+
return f"{base}; did you mean: {', '.join(ids)}?"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def analyze_drift(
|
|
114
|
+
db_path: str,
|
|
115
|
+
patterns_path: str,
|
|
116
|
+
max_drift: float = 0.50,
|
|
117
|
+
profile: str | None = None,
|
|
118
|
+
max_residual: float = 0.45,
|
|
119
|
+
) -> DriftAnalysis:
|
|
120
|
+
"""Score every project domain (and the quotient level) against patterns.
|
|
121
|
+
|
|
122
|
+
When ``profile`` is set, only domains (and project_level bindings) whose
|
|
123
|
+
``binding.profile == profile`` (or ``binding.profile is None``) are scored.
|
|
124
|
+
Profile-less domains match any filter — they carry language-agnostic hygiene
|
|
125
|
+
rules that should apply regardless of which language graph is being measured.
|
|
126
|
+
Omitting ``profile`` retains the score-everything default.
|
|
127
|
+
|
|
128
|
+
``any_critical`` is True when any ENFORCED binding has status in
|
|
129
|
+
``{"critical", "gate_failed", "empty"}`` (spec §2.3, #170B). The
|
|
130
|
+
per-domain ``drift_tolerance`` determines whether a score yields
|
|
131
|
+
``"critical"``; ``max_drift`` is the fallback default tolerance for domains
|
|
132
|
+
that do not declare their own (``drift_tolerance`` is no longer a global cap).
|
|
133
|
+
``"no_signal"`` never trips the gate. ``enforce: false`` stays observe-only.
|
|
134
|
+
|
|
135
|
+
For each ``"empty"`` report, the ``note`` field is decorated with closest-prefix
|
|
136
|
+
suggestions from the suffix index (spec §2.4).
|
|
137
|
+
|
|
138
|
+
Each profiled report with a real shape also carries ``fit`` (#177): the nearest
|
|
139
|
+
alphabet template + residual, the runner-up, and a ``good|weak|none`` band
|
|
140
|
+
(``none`` when the nearest residual exceeds ``max_residual`` — no archetype
|
|
141
|
+
captures the domain). ``DriftAnalysis.coverage`` lists graph prefixes bound by
|
|
142
|
+
no project_domain (code drift silently ignores).
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
FileNotFoundError: If ``db_path`` does not point to an existing file.
|
|
146
|
+
Use ``cgis ingest`` to create the graph database first.
|
|
147
|
+
ValueError: If ``patterns_path`` does not end in ``.yaml`` or ``.yml``.
|
|
148
|
+
FileNotFoundError: If ``patterns_path`` does not point to an existing file.
|
|
149
|
+
"""
|
|
150
|
+
if not Path(db_path).is_file():
|
|
151
|
+
msg = f"Graph database not found: {db_path}"
|
|
152
|
+
raise FileNotFoundError(msg)
|
|
153
|
+
resolved = Path(patterns_path).resolve()
|
|
154
|
+
if resolved.suffix.lower() not in _ALLOWED_PATTERN_SUFFIXES:
|
|
155
|
+
msg = f"patterns_path must be a .yaml or .yml file, got: {patterns_path!r}"
|
|
156
|
+
raise ValueError(msg)
|
|
157
|
+
if not resolved.is_file():
|
|
158
|
+
msg = f"Patterns file not found: {patterns_path}"
|
|
159
|
+
raise FileNotFoundError(msg)
|
|
160
|
+
scorer = DriftScorer(str(resolved))
|
|
161
|
+
domains = scorer.load_project_domains()
|
|
162
|
+
if profile is not None:
|
|
163
|
+
domains = [d for d in domains if d.profile is None or d.profile == profile]
|
|
164
|
+
|
|
165
|
+
quotient: list[tuple[DomainConfig, DriftReport]] = []
|
|
166
|
+
with SQLiteStore(db_path) as store:
|
|
167
|
+
extractor = FingerprintExtractor(store)
|
|
168
|
+
reports = [
|
|
169
|
+
scorer.score(extractor.extract(domain.fqn_prefix), domain, default_tolerance=max_drift)
|
|
170
|
+
for domain in domains
|
|
171
|
+
]
|
|
172
|
+
reports = [
|
|
173
|
+
dataclasses.replace(r, note=_empty_note(store, r.fqn_prefix))
|
|
174
|
+
if r.status == "empty"
|
|
175
|
+
else r
|
|
176
|
+
for r in reports
|
|
177
|
+
]
|
|
178
|
+
# Annotate fit-quality for domains with a real shape and a profile (#177).
|
|
179
|
+
reports = [
|
|
180
|
+
dataclasses.replace(r, fit=_fit_quality(scorer, r, d.profile, max_residual))
|
|
181
|
+
if d.profile is not None and r.status in _FITTABLE_STATUSES
|
|
182
|
+
else r
|
|
183
|
+
for d, r in zip(domains, reports, strict=True)
|
|
184
|
+
]
|
|
185
|
+
all_nodes = store.get_all_nodes() # one materialization, reused below (#177 #4)
|
|
186
|
+
coverage = _uncovered_prefixes(all_nodes, [d.fqn_prefix for d in domains])
|
|
187
|
+
level_bindings = scorer.load_project_level()
|
|
188
|
+
if profile is not None:
|
|
189
|
+
level_bindings = [
|
|
190
|
+
b for b in level_bindings if b.profile is None or b.profile == profile
|
|
191
|
+
]
|
|
192
|
+
if level_bindings:
|
|
193
|
+
qnodes, qedges = build_quotient(all_nodes, store.get_all_edges(), domains)
|
|
194
|
+
q_extractor = FingerprintExtractor.from_graph(qnodes, qedges)
|
|
195
|
+
quotient = [
|
|
196
|
+
(b, scorer.score(q_extractor.extract(b.fqn_prefix), b, default_tolerance=max_drift))
|
|
197
|
+
for b in level_bindings
|
|
198
|
+
]
|
|
199
|
+
quotient = [
|
|
200
|
+
(b, dataclasses.replace(r, note=_empty_note(store, r.fqn_prefix)))
|
|
201
|
+
if r.status == "empty"
|
|
202
|
+
else (b, r)
|
|
203
|
+
for b, r in quotient
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
# Gate is now uniformly status-based and enforce-respecting (spec §2.3,
|
|
207
|
+
# #170B): 'critical' and 'gate_failed' on enforced bindings trip any_critical;
|
|
208
|
+
# 'empty' on enforced bindings also trips it (broken fqn_prefix must not
|
|
209
|
+
# silently pass CI). 'no_signal' never trips. enforce:false stays observe-only.
|
|
210
|
+
any_critical = any(
|
|
211
|
+
r.status in ("critical", "gate_failed", "empty")
|
|
212
|
+
for d, r in zip(domains, reports, strict=True)
|
|
213
|
+
if d.enforce
|
|
214
|
+
) or any(r.status in ("critical", "gate_failed", "empty") for b, r in quotient if b.enforce)
|
|
215
|
+
return DriftAnalysis(
|
|
216
|
+
reports=reports, quotient=quotient, any_critical=any_critical, coverage=coverage
|
|
217
|
+
)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""PatternFingerprint dataclass and FingerprintExtractor."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from cgis.core.models import Edge, EdgeType, Node, NodeType
|
|
7
|
+
from cgis.query.analysis.health import HealthScorer
|
|
8
|
+
from cgis.query.drift._scc import build_adjacency, tarjan_scc
|
|
9
|
+
from cgis.query.drift.triads import ZERO_TRIADS, normalized_census, tangle_mass, triad_census
|
|
10
|
+
from cgis.storage.sqlite_store import SQLiteStore
|
|
11
|
+
|
|
12
|
+
RAW_CALL_PREFIX = "raw_call:"
|
|
13
|
+
|
|
14
|
+
_HUB_FAN_IN_THRESHOLD = 2
|
|
15
|
+
_STAR_FAN_OUT_THRESHOLD = 3
|
|
16
|
+
_ROUTER_FAN_OUT_THRESHOLD = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class PatternFingerprint:
|
|
21
|
+
"""Structural fingerprint for a domain: seven v1 counters plus two v2 census vectors."""
|
|
22
|
+
|
|
23
|
+
domain: str
|
|
24
|
+
|
|
25
|
+
hub_count: int
|
|
26
|
+
star_count: int
|
|
27
|
+
chain_len: float
|
|
28
|
+
dag_depth: int
|
|
29
|
+
router_count: int
|
|
30
|
+
|
|
31
|
+
cycle_ratio: float
|
|
32
|
+
unresolved_ratio: float
|
|
33
|
+
|
|
34
|
+
# Fingerprint v2 (spec §3.2): normalized 13-triad census per layer,
|
|
35
|
+
# ordered by triads.TRIAD_ORDER. Zero vector = empty layer ("no data").
|
|
36
|
+
t_imports: tuple[float, ...] = field(default=ZERO_TRIADS)
|
|
37
|
+
t_calls: tuple[float, ...] = field(default=ZERO_TRIADS)
|
|
38
|
+
|
|
39
|
+
# How many graph nodes / intra-domain edges the selector matched.
|
|
40
|
+
# Defaults are 1 ("hand-built fingerprints are assumed measurable",
|
|
41
|
+
# spec Amendment 1); only extract() produces real zeros. 0 nodes means
|
|
42
|
+
# the fqn_prefix selected nothing; 0 edges with >0 nodes means there is
|
|
43
|
+
# no structure to score (isolated symbols / alias-only matches).
|
|
44
|
+
node_count: int = 1
|
|
45
|
+
edge_count: int = 1
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def tangle_ratio(self) -> float:
|
|
49
|
+
"""Worst-layer normalized mutual-motif mass, in [0, 1] (spec #186).
|
|
50
|
+
|
|
51
|
+
max over the IMPORTS and CALLS census of tangle_mass — a hard hygiene
|
|
52
|
+
signal: a breach in either layer is a breach. 0 for empty or
|
|
53
|
+
antisymmetric (no-mutual-dyad) layers.
|
|
54
|
+
|
|
55
|
+
The CALLS layer is faded by the confidence discount
|
|
56
|
+
``clip(1 - unresolved_ratio)`` before the max (#244), mirroring the
|
|
57
|
+
drift-distance path (``_clip_discount`` / ``_score_v2``): ``t_calls`` is a
|
|
58
|
+
census over RESOLVED intra-domain calls only, so a sparse/mostly-unresolved
|
|
59
|
+
call graph would otherwise over-report calls-tangle and trip the gate on
|
|
60
|
+
thin evidence. The IMPORTS layer (always resolved) is never discounted.
|
|
61
|
+
"""
|
|
62
|
+
calls_discount = max(0.0, min(1.0 - self.unresolved_ratio, 1.0))
|
|
63
|
+
return max(tangle_mass(self.t_imports), calls_discount * tangle_mass(self.t_calls))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _in_domain(fqn: str, prefix: str) -> bool:
|
|
67
|
+
"""Return True iff fqn is the prefix itself or a child of it (segment-boundary aware)."""
|
|
68
|
+
return fqn == prefix or fqn.startswith(prefix + ".")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _root_depths(domain_ids: set[str], edges: list[Edge], edge_type: EdgeType) -> list[int]:
|
|
72
|
+
"""Longest-path depth from each root node along edges of edge_type within the domain.
|
|
73
|
+
|
|
74
|
+
A root is a domain node with outgoing edges of the given type but no incoming ones.
|
|
75
|
+
Uses memoized DFS (cycle-safe); roots are sorted so results are deterministic.
|
|
76
|
+
"""
|
|
77
|
+
adj: dict[str, list[str]] = {}
|
|
78
|
+
has_incoming: set[str] = set()
|
|
79
|
+
for e in edges:
|
|
80
|
+
if e.type == edge_type:
|
|
81
|
+
adj.setdefault(e.source, []).append(e.target)
|
|
82
|
+
has_incoming.add(e.target)
|
|
83
|
+
|
|
84
|
+
roots = sorted(n for n in domain_ids if n not in has_incoming and n in adj)
|
|
85
|
+
if not roots:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
memo: dict[str, int] = {}
|
|
89
|
+
visiting: set[str] = set()
|
|
90
|
+
|
|
91
|
+
def dfs(u: str) -> int:
|
|
92
|
+
if u in memo:
|
|
93
|
+
return memo[u]
|
|
94
|
+
if u in visiting:
|
|
95
|
+
return 0
|
|
96
|
+
visiting.add(u)
|
|
97
|
+
val = max((1 + dfs(v) for v in adj.get(u, [])), default=0)
|
|
98
|
+
visiting.remove(u)
|
|
99
|
+
memo[u] = val
|
|
100
|
+
return val
|
|
101
|
+
|
|
102
|
+
return [dfs(root) for root in roots]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _avg_chain_length(domain_ids: set[str], internal_calls: list[Edge]) -> float:
|
|
106
|
+
"""Average longest-path depth from each source node along CALLS edges within the domain."""
|
|
107
|
+
depths = _root_depths(domain_ids, internal_calls, EdgeType.CALLS)
|
|
108
|
+
return sum(depths) / len(depths) if depths else 0.0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _max_dag_depth(domain_ids: set[str], internal_edges: list[Edge]) -> int:
|
|
112
|
+
"""Max longest-path depth along IMPORTS edges within the domain (from import-root nodes)."""
|
|
113
|
+
depths = _root_depths(domain_ids, internal_edges, EdgeType.IMPORTS)
|
|
114
|
+
return max(depths) if depths else 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _count_routers(domain_node_ids: set[str], all_edges: list[Edge]) -> int:
|
|
118
|
+
"""Count domain nodes with fan_out > 2 whose CALLS targets don't import them."""
|
|
119
|
+
imported_by: dict[str, set[str]] = {}
|
|
120
|
+
calls_by_source: dict[str, list[str]] = {}
|
|
121
|
+
for e in all_edges:
|
|
122
|
+
if e.type == EdgeType.IMPORTS:
|
|
123
|
+
imported_by.setdefault(e.target, set()).add(e.source)
|
|
124
|
+
elif e.type == EdgeType.CALLS:
|
|
125
|
+
calls_by_source.setdefault(e.source, []).append(e.target)
|
|
126
|
+
|
|
127
|
+
router_count = 0
|
|
128
|
+
for node_id in domain_node_ids:
|
|
129
|
+
calls_targets = calls_by_source.get(node_id, [])
|
|
130
|
+
if len(calls_targets) <= _ROUTER_FAN_OUT_THRESHOLD:
|
|
131
|
+
continue
|
|
132
|
+
node_importers = imported_by.get(node_id, set())
|
|
133
|
+
if not (set(calls_targets) & node_importers):
|
|
134
|
+
router_count += 1
|
|
135
|
+
|
|
136
|
+
return router_count
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class FingerprintExtractor:
|
|
140
|
+
"""Compute a PatternFingerprint for a given FQN domain prefix from a SQLiteStore."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, store: SQLiteStore | None) -> None:
|
|
143
|
+
"""Accept an open SQLiteStore, or None when built via from_graph()."""
|
|
144
|
+
self._store = store
|
|
145
|
+
self._cache: tuple[list[Node], list[Edge]] | None = None
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def from_graph(cls, nodes: list[Node], edges: list[Edge]) -> "FingerprintExtractor":
|
|
149
|
+
"""Build an extractor over an in-memory graph — no SQLiteStore involved.
|
|
150
|
+
|
|
151
|
+
The inputs are copied defensively, so later mutation of the caller's
|
|
152
|
+
lists cannot change what extract() measures (matches the store path,
|
|
153
|
+
which returns fresh lists on every fetch).
|
|
154
|
+
"""
|
|
155
|
+
inst = cls(None)
|
|
156
|
+
inst._cache = (HealthScorer(nodes, edges).enrich(), list(edges))
|
|
157
|
+
return inst
|
|
158
|
+
|
|
159
|
+
def _loaded(self) -> tuple[list[Node], list[Edge]]:
|
|
160
|
+
"""Return (enriched_nodes, all_edges), fetching from the store once and caching."""
|
|
161
|
+
if self._cache is None:
|
|
162
|
+
if self._store is None:
|
|
163
|
+
msg = "FingerprintExtractor needs a store or a from_graph() preload."
|
|
164
|
+
raise RuntimeError(msg)
|
|
165
|
+
all_nodes = self._store.get_all_nodes()
|
|
166
|
+
all_edges = self._store.get_all_edges()
|
|
167
|
+
self._cache = (HealthScorer(all_nodes, all_edges).enrich(), all_edges)
|
|
168
|
+
return self._cache
|
|
169
|
+
|
|
170
|
+
def extract(self, fqn_prefix: str) -> PatternFingerprint:
|
|
171
|
+
"""Return the structural fingerprint for all nodes under fqn_prefix."""
|
|
172
|
+
enriched_all, all_edges = self._loaded()
|
|
173
|
+
|
|
174
|
+
domain_nodes = [n for n in enriched_all if _in_domain(n.id, fqn_prefix)]
|
|
175
|
+
if not domain_nodes:
|
|
176
|
+
return PatternFingerprint(
|
|
177
|
+
domain=fqn_prefix,
|
|
178
|
+
hub_count=0,
|
|
179
|
+
star_count=0,
|
|
180
|
+
chain_len=0.0,
|
|
181
|
+
dag_depth=0,
|
|
182
|
+
router_count=0,
|
|
183
|
+
cycle_ratio=0.0,
|
|
184
|
+
unresolved_ratio=0.0,
|
|
185
|
+
node_count=0,
|
|
186
|
+
edge_count=0,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
domain_ids = {n.id for n in domain_nodes}
|
|
190
|
+
|
|
191
|
+
internal_edges = [e for e in all_edges if e.source in domain_ids and e.target in domain_ids]
|
|
192
|
+
domain_outgoing = [e for e in all_edges if e.source in domain_ids]
|
|
193
|
+
|
|
194
|
+
# Fan-in/fan-out over intra-domain CALLS only — external targets
|
|
195
|
+
# (stdlib, other domains) would otherwise inflate star/hub counts.
|
|
196
|
+
fan_in = Counter(e.target for e in internal_edges if e.type == EdgeType.CALLS)
|
|
197
|
+
fan_out = Counter(e.source for e in internal_edges if e.type == EdgeType.CALLS)
|
|
198
|
+
|
|
199
|
+
hub_count = sum(
|
|
200
|
+
1 for n in domain_nodes if fan_in[n.id] > _HUB_FAN_IN_THRESHOLD and fan_out[n.id] == 0
|
|
201
|
+
)
|
|
202
|
+
star_count = sum(
|
|
203
|
+
1 for n in domain_nodes if fan_out[n.id] > _STAR_FAN_OUT_THRESHOLD and fan_in[n.id] <= 1
|
|
204
|
+
)
|
|
205
|
+
chain_len = _avg_chain_length(domain_ids, internal_edges)
|
|
206
|
+
dag_depth = _max_dag_depth(domain_ids, internal_edges)
|
|
207
|
+
router_count = _count_routers(domain_ids, all_edges)
|
|
208
|
+
|
|
209
|
+
cycle_ratio = self._intra_domain_cycle_ratio(domain_nodes, all_edges)
|
|
210
|
+
|
|
211
|
+
calls_edges = [e for e in domain_outgoing if e.type == EdgeType.CALLS]
|
|
212
|
+
raw_calls = [e for e in calls_edges if e.target.startswith(RAW_CALL_PREFIX)]
|
|
213
|
+
unresolved_ratio = len(raw_calls) / len(calls_edges) if calls_edges else 0.0
|
|
214
|
+
|
|
215
|
+
t_imports = normalized_census(triad_census(domain_ids, internal_edges, EdgeType.IMPORTS))
|
|
216
|
+
t_calls = normalized_census(triad_census(domain_ids, internal_edges, EdgeType.CALLS))
|
|
217
|
+
|
|
218
|
+
return PatternFingerprint(
|
|
219
|
+
domain=fqn_prefix,
|
|
220
|
+
hub_count=hub_count,
|
|
221
|
+
star_count=star_count,
|
|
222
|
+
chain_len=chain_len,
|
|
223
|
+
dag_depth=dag_depth,
|
|
224
|
+
router_count=router_count,
|
|
225
|
+
cycle_ratio=cycle_ratio,
|
|
226
|
+
unresolved_ratio=unresolved_ratio,
|
|
227
|
+
t_imports=t_imports,
|
|
228
|
+
t_calls=t_calls,
|
|
229
|
+
node_count=len(domain_nodes),
|
|
230
|
+
edge_count=len(internal_edges),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _intra_domain_cycle_ratio(self, domain_nodes: list[Node], all_edges: list[Edge]) -> float:
|
|
234
|
+
"""Blast radius of the domain's OWN import cycles (spec §2.1, #176).
|
|
235
|
+
|
|
236
|
+
Tarjan SCC over IMPORTS edges whose endpoints are both FILE/MODULE
|
|
237
|
+
nodes of this domain; the ratio counts domain nodes living in files
|
|
238
|
+
that participate in an intra-domain cycle. Single-file domains are
|
|
239
|
+
0.0 by construction; cross-domain cycles are the quotient layer's
|
|
240
|
+
concern and never count here.
|
|
241
|
+
"""
|
|
242
|
+
file_types = {NodeType.FILE, NodeType.MODULE}
|
|
243
|
+
domain_files = {n.id for n in domain_nodes if n.type in file_types}
|
|
244
|
+
if len(domain_files) < 2:
|
|
245
|
+
return 0.0
|
|
246
|
+
adj = build_adjacency(all_edges, frozenset({EdgeType.IMPORTS}))
|
|
247
|
+
adj = {
|
|
248
|
+
k: [v for v in vs if v in domain_files] for k, vs in adj.items() if k in domain_files
|
|
249
|
+
}
|
|
250
|
+
cyclic_files = {n for scc in tarjan_scc(adj) if len(scc) > 1 for n in scc}
|
|
251
|
+
if not cyclic_files:
|
|
252
|
+
return 0.0
|
|
253
|
+
cyclic_paths = {n.file_path for n in domain_nodes if n.id in cyclic_files}
|
|
254
|
+
cycle_count = sum(1 for n in domain_nodes if n.file_path in cyclic_paths)
|
|
255
|
+
return cycle_count / len(domain_nodes)
|