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,470 @@
|
|
|
1
|
+
"""Auto-propose a starter patterns.yaml from the measured graph (#174).
|
|
2
|
+
|
|
3
|
+
Measure-then-label: discover candidate domains from FQN structure, fit each
|
|
4
|
+
against the bundled pattern templates by scoring with the existing
|
|
5
|
+
DriftScorer, and emit a ready-to-edit ontology whose tolerances are the
|
|
6
|
+
measured values plus a margin — green by construction on the same graph.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from cgis.core.models import VIRTUAL_FILE_PATH, Node
|
|
17
|
+
from cgis.query.drift.drift import DomainConfig, DriftScorer
|
|
18
|
+
from cgis.query.drift.fingerprint import FingerprintExtractor, PatternFingerprint
|
|
19
|
+
from cgis.storage.sqlite_store import SQLiteStore
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Bundled default ontology header (version/profiles/patterns/hygiene only).
|
|
23
|
+
# project_domains and project_level are intentionally excluded — those are
|
|
24
|
+
# the generated output. The staleness pin in the test suite guards this block
|
|
25
|
+
# against drift from docs/ontology/patterns.yaml.
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
_DEFAULT_ONTOLOGY_HEADER = """\
|
|
29
|
+
# Generated by `cgis init-ontology` — a measured baseline, not a verdict.
|
|
30
|
+
# Tolerances are measured + margin: ratchet them DOWN over time.
|
|
31
|
+
# Docs: docs/specs/2026-06-12-init-ontology-design.md
|
|
32
|
+
version: "2.1.0"
|
|
33
|
+
|
|
34
|
+
# ── Measurement profiles (spec §2.3, §3.3) ───────────────────────────────────
|
|
35
|
+
# drift_weights: v1 per-component weights — now used ONLY for the gate term
|
|
36
|
+
# (hygiene + template-declared non-triad constraints).
|
|
37
|
+
# layers: fingerprint v2 layer weights (imports TV / calls TV / gates),
|
|
38
|
+
# must sum to 1.0. The calls layer is discounted by (1 - unresolved_ratio);
|
|
39
|
+
# a layer with zero connected triples is excluded and the rest renormalize.
|
|
40
|
+
# triad_weights: per-triad w_i for the TV metric; unlisted triads weigh 1.0.
|
|
41
|
+
profiles:
|
|
42
|
+
python:
|
|
43
|
+
drift_weights:
|
|
44
|
+
hub_count: 0.15
|
|
45
|
+
star_count: 0.15
|
|
46
|
+
chain_len: 0.10
|
|
47
|
+
dag_depth: 0.10
|
|
48
|
+
router_count: 0.10
|
|
49
|
+
cycle_ratio: 0.25
|
|
50
|
+
unresolved_ratio: 0.15
|
|
51
|
+
layers:
|
|
52
|
+
imports: 0.35
|
|
53
|
+
calls: 0.35
|
|
54
|
+
gates: 0.30
|
|
55
|
+
triad_weights: {}
|
|
56
|
+
typescript:
|
|
57
|
+
drift_weights:
|
|
58
|
+
hub_count: 0.10
|
|
59
|
+
star_count: 0.10
|
|
60
|
+
chain_len: 0.10
|
|
61
|
+
dag_depth: 0.15
|
|
62
|
+
router_count: 0.10
|
|
63
|
+
cycle_ratio: 0.30
|
|
64
|
+
unresolved_ratio: 0.15
|
|
65
|
+
layers:
|
|
66
|
+
imports: 0.45
|
|
67
|
+
calls: 0.25
|
|
68
|
+
gates: 0.30
|
|
69
|
+
triad_weights: {}
|
|
70
|
+
|
|
71
|
+
# ── Global hygiene invariants (spec §2.1) — hard gates, never distances ──────
|
|
72
|
+
hygiene:
|
|
73
|
+
cycle_ratio: {max: 0.0}
|
|
74
|
+
unresolved_ratio: {max: 0.2}
|
|
75
|
+
tangle_ratio: {max: 0.25}
|
|
76
|
+
|
|
77
|
+
# ── The closed alphabet: five templates (spec §2.1, §3.3) ────────────────────
|
|
78
|
+
# ideal: hand-authored points in 13-triad space, one per layer, each summing
|
|
79
|
+
# to 1.0. Counting constraints (hub/star/chain/router) are GONE — topology is
|
|
80
|
+
# measured as TV distance to the ideal. Non-triad structural gates (dag_depth)
|
|
81
|
+
# and template-specific hygiene remain as constraints.
|
|
82
|
+
patterns:
|
|
83
|
+
pure_utility:
|
|
84
|
+
description: "Shared library called by many; depends on nothing"
|
|
85
|
+
ideal:
|
|
86
|
+
imports: {"021U": 1.0}
|
|
87
|
+
calls: {"021U": 1.0}
|
|
88
|
+
unresolved_ratio: {max: 0.1}
|
|
89
|
+
|
|
90
|
+
pipeline_stage:
|
|
91
|
+
description: "Sequential transformer — one input domain, one output domain"
|
|
92
|
+
ideal:
|
|
93
|
+
imports: {"021C": 1.0}
|
|
94
|
+
calls: {"021C": 1.0}
|
|
95
|
+
|
|
96
|
+
orchestrator:
|
|
97
|
+
description: "Coordinates N independent services; no leaf-to-leaf edges"
|
|
98
|
+
ideal:
|
|
99
|
+
imports: {"021D": 1.0}
|
|
100
|
+
calls: {"021D": 1.0}
|
|
101
|
+
|
|
102
|
+
layered_dag:
|
|
103
|
+
description: "Clean layered architecture; no upward dependencies"
|
|
104
|
+
params:
|
|
105
|
+
min_depth: 3
|
|
106
|
+
ideal:
|
|
107
|
+
imports: {"021D": 0.5, "021C": 0.5}
|
|
108
|
+
calls: {"021D": 0.5, "021C": 0.5}
|
|
109
|
+
dag_depth: {min: $min_depth}
|
|
110
|
+
|
|
111
|
+
dispatcher:
|
|
112
|
+
description: "Routes to the first matching strategy; mutually exclusive paths"
|
|
113
|
+
ideal:
|
|
114
|
+
imports: {"021D": 1.0}
|
|
115
|
+
calls: {"021D": 1.0}
|
|
116
|
+
|
|
117
|
+
funnel:
|
|
118
|
+
# Transpose of layered_dag — fan-in with internal staging (#186).
|
|
119
|
+
description: "Convergent aggregation — many inputs funnel through staged sinks"
|
|
120
|
+
ideal:
|
|
121
|
+
imports: {"021U": 0.5, "021C": 0.5}
|
|
122
|
+
calls: {"021U": 0.5, "021C": 0.5}
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Constants
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
_NO_FIT_THRESHOLD = 0.5
|
|
131
|
+
_TS_EXTENSIONS = frozenset({".ts", ".tsx", ".js", ".jsx", ".vue"})
|
|
132
|
+
|
|
133
|
+
# Parse the bundled header once at module load (used by _baseline_lines for the
|
|
134
|
+
# hygiene block; template ranking now lives on DriftScorer.fit_templates).
|
|
135
|
+
_PARSED_HEADER: dict[str, object] = yaml.safe_load(_DEFAULT_ONTOLOGY_HEADER)
|
|
136
|
+
|
|
137
|
+
# Commented-out project_level skeleton appended after project_domains.
|
|
138
|
+
_PROJECT_LEVEL_SKELETON = """\
|
|
139
|
+
# project_level: # quotient binding is an architectural decision — uncomment and tune:
|
|
140
|
+
# - name: "whole"
|
|
141
|
+
# fqn_prefix: "<collapse-prefix>"
|
|
142
|
+
# expected_pattern: pipeline_stage
|
|
143
|
+
# profile: python
|
|
144
|
+
# drift_tolerance: 0.50
|
|
145
|
+
# enforce: false
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Helper functions
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _in_prefix(fqn: str, prefix: str) -> bool:
|
|
155
|
+
"""Return True iff fqn equals prefix or is a child of it (segment-boundary-safe)."""
|
|
156
|
+
return fqn == prefix or fqn.startswith(prefix + ".")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _detect_profile(domain_nodes: list[Node]) -> tuple[str, bool]:
|
|
160
|
+
"""Return (profile, guessed): majority file extension decides the profile.
|
|
161
|
+
|
|
162
|
+
Maps ``.py`` → ``python``; ``.ts``/``.tsx``/``.js``/``.jsx``/``.vue`` →
|
|
163
|
+
``typescript``. Ties or unknown extensions fall back to ``("python", True)``
|
|
164
|
+
with a ``# profile guessed`` annotation.
|
|
165
|
+
"""
|
|
166
|
+
py_count = 0
|
|
167
|
+
ts_count = 0
|
|
168
|
+
for n in domain_nodes:
|
|
169
|
+
ext = Path(n.file_path).suffix.lower()
|
|
170
|
+
if ext == ".py":
|
|
171
|
+
py_count += 1
|
|
172
|
+
elif ext in _TS_EXTENSIONS:
|
|
173
|
+
ts_count += 1
|
|
174
|
+
|
|
175
|
+
if py_count > ts_count:
|
|
176
|
+
return "python", False
|
|
177
|
+
if ts_count > py_count:
|
|
178
|
+
return "typescript", False
|
|
179
|
+
return "python", True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _fit_templates(
|
|
183
|
+
fp: PatternFingerprint,
|
|
184
|
+
profile: str,
|
|
185
|
+
scorer: DriftScorer,
|
|
186
|
+
) -> list[tuple[str, float]]:
|
|
187
|
+
"""Return ``[(template_name, fit_score)]`` sorted by (score, name).
|
|
188
|
+
|
|
189
|
+
Thin wrapper over ``DriftScorer.fit_templates`` — the canonical
|
|
190
|
+
distance-to-template, shared with fit-quality reporting (#177) so both
|
|
191
|
+
surfaces rank templates identically.
|
|
192
|
+
"""
|
|
193
|
+
return scorer.fit_templates(fp, profile)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _ceil2(x: float) -> float:
|
|
197
|
+
"""Ceil x to 2 decimal places (tolerance rounding — always rounds up)."""
|
|
198
|
+
return math.ceil(x * 100) / 100
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _floor2(x: float) -> float:
|
|
202
|
+
"""Floor x to 2 decimal places (min-constraint baseline rounding — always rounds down)."""
|
|
203
|
+
return math.floor(x * 100) / 100
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _hygiene_score(
|
|
207
|
+
fp: PatternFingerprint, prefix: str, scorer: DriftScorer, profile: str | None
|
|
208
|
+
) -> float:
|
|
209
|
+
"""Return the hygiene drift score for a domain scored with the given profile.
|
|
210
|
+
|
|
211
|
+
Used to set tolerances for hygiene-only entries. The profile MUST match the
|
|
212
|
+
one emitted into the YAML (detected by ``_detect_profile``): if ``profile``
|
|
213
|
+
differs from the emitted value the v1 drift_weights used to score
|
|
214
|
+
``cycle_ratio`` and ``unresolved_ratio`` will diverge, breaking the
|
|
215
|
+
round-trip guarantee (spec §4.3) on domains that have cycles.
|
|
216
|
+
"""
|
|
217
|
+
cfg = DomainConfig(
|
|
218
|
+
name=prefix,
|
|
219
|
+
fqn_prefix=prefix,
|
|
220
|
+
expected_pattern=None,
|
|
221
|
+
profile=profile,
|
|
222
|
+
drift_tolerance=1.0,
|
|
223
|
+
)
|
|
224
|
+
return scorer.score(fp, cfg).drift_score
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _baseline_lines(
|
|
228
|
+
fp: PatternFingerprint,
|
|
229
|
+
hygiene: dict[str, object] | None = None,
|
|
230
|
+
) -> list[str]:
|
|
231
|
+
"""Return YAML lines for a ``hygiene_baseline:`` block, if any key breaches its global bound.
|
|
232
|
+
|
|
233
|
+
For each hygiene key, if the measured fingerprint value breaches the GLOBAL bound
|
|
234
|
+
(max → measured > bound; min → measured < bound; exact → skipped entirely),
|
|
235
|
+
the key is collected with an operator-correct rounded value.
|
|
236
|
+
|
|
237
|
+
Rounding is operator-aware (spec §2.2, #221 review):
|
|
238
|
+
- ``max`` constraints: round UP via ``_ceil2`` — a floored value below the true
|
|
239
|
+
measurement would gate_fail the proposal on its own graph (the round-trip guarantee).
|
|
240
|
+
- ``min`` constraints: round DOWN via ``_floor2`` — a ceiled value above the true
|
|
241
|
+
measurement would gate_fail the proposal on its own graph.
|
|
242
|
+
- ``exact`` constraints: SKIPPED — an exact-bound acknowledgment would pin the value
|
|
243
|
+
and re-fail on improvement; exact breaches remain visible as gate_failed in the
|
|
244
|
+
proposal and cannot be acknowledged by init-ontology by design.
|
|
245
|
+
|
|
246
|
+
``hygiene``: optional parsed hygiene mapping (for testing); defaults to the bundled
|
|
247
|
+
``_PARSED_HEADER["hygiene"]`` block when not supplied.
|
|
248
|
+
Returns an empty list when no key breaches its bound.
|
|
249
|
+
"""
|
|
250
|
+
if hygiene is None:
|
|
251
|
+
_hygiene_obj = _PARSED_HEADER.get("hygiene") or {}
|
|
252
|
+
hygiene = _hygiene_obj if isinstance(_hygiene_obj, dict) else {}
|
|
253
|
+
items: list[str] = []
|
|
254
|
+
for key, constraint in hygiene.items():
|
|
255
|
+
if not isinstance(constraint, dict):
|
|
256
|
+
continue
|
|
257
|
+
measured = float(getattr(fp, key, 0.0))
|
|
258
|
+
if "max" in constraint and measured > float(constraint["max"]):
|
|
259
|
+
rounded = _ceil2(measured)
|
|
260
|
+
items.append(
|
|
261
|
+
f" {key}: {rounded}"
|
|
262
|
+
" # acknowledged at baseline by init-ontology — ratchet down over time"
|
|
263
|
+
)
|
|
264
|
+
elif "min" in constraint and measured < float(constraint["min"]):
|
|
265
|
+
rounded = _floor2(measured)
|
|
266
|
+
items.append(
|
|
267
|
+
f" {key}: {rounded}"
|
|
268
|
+
" # acknowledged at baseline by init-ontology — ratchet down over time"
|
|
269
|
+
)
|
|
270
|
+
# exact: skipped — pins value and would re-fail on improvement; unacknowledgeable by design.
|
|
271
|
+
return items
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _domain_entry(
|
|
275
|
+
prefix: str,
|
|
276
|
+
all_nodes: list[Node],
|
|
277
|
+
extractor: FingerprintExtractor,
|
|
278
|
+
scorer: DriftScorer,
|
|
279
|
+
margin: float,
|
|
280
|
+
min_nodes: int,
|
|
281
|
+
name_is_unique: bool,
|
|
282
|
+
) -> str:
|
|
283
|
+
"""Return the YAML block string for one project domain entry.
|
|
284
|
+
|
|
285
|
+
Decision ladder (spec §2.1.5):
|
|
286
|
+
1. node_count < min_nodes → hygiene-only with census-too-small comment.
|
|
287
|
+
2. edge_count == 0 → hygiene-only with no-intra-domain-edges comment.
|
|
288
|
+
3. best fit > _NO_FIT_THRESHOLD → hygiene-only with no-template-fits comment.
|
|
289
|
+
4. Otherwise → labeled with expected_pattern, tolerance, and runner-up comment.
|
|
290
|
+
|
|
291
|
+
Tolerances for hygiene-only entries use the v1 hygiene score + margin.
|
|
292
|
+
Tolerances for labeled entries use the best-fit score + margin.
|
|
293
|
+
"""
|
|
294
|
+
fp = extractor.extract(prefix)
|
|
295
|
+
domain_nodes = [
|
|
296
|
+
n for n in all_nodes if _in_prefix(n.id, prefix) and n.file_path != VIRTUAL_FILE_PATH
|
|
297
|
+
]
|
|
298
|
+
profile, guessed = _detect_profile(domain_nodes)
|
|
299
|
+
fits = _fit_templates(fp, profile, scorer)
|
|
300
|
+
best_name, best = fits[0]
|
|
301
|
+
runner_name, runner = fits[1] if len(fits) > 1 else (fits[0][0], fits[0][1])
|
|
302
|
+
|
|
303
|
+
# name: last segment, or full prefix when it collides.
|
|
304
|
+
short_name = prefix.rsplit(".", maxsplit=1)[-1]
|
|
305
|
+
entry_name = short_name if name_is_unique else prefix
|
|
306
|
+
|
|
307
|
+
lines = [f' - name: "{entry_name}"', f' fqn_prefix: "{prefix}"']
|
|
308
|
+
|
|
309
|
+
profile_suffix = " # profile guessed" if guessed else ""
|
|
310
|
+
|
|
311
|
+
# Hygiene-only reason, or None when the domain earns a template label.
|
|
312
|
+
if fp.node_count < min_nodes:
|
|
313
|
+
reason = f"# below min_nodes ({fp.node_count} nodes) — census too small to label"
|
|
314
|
+
elif fp.edge_count == 0:
|
|
315
|
+
reason = "# no intra-domain edges — nothing to fit"
|
|
316
|
+
elif best > _NO_FIT_THRESHOLD:
|
|
317
|
+
reason = f"# no template fits (best: {best_name} at {best:.2f})"
|
|
318
|
+
else:
|
|
319
|
+
reason = None
|
|
320
|
+
|
|
321
|
+
if reason is not None:
|
|
322
|
+
tolerance = _ceil2(_hygiene_score(fp, prefix, scorer, profile) + margin)
|
|
323
|
+
comment = reason
|
|
324
|
+
else:
|
|
325
|
+
tolerance = _ceil2(best + margin)
|
|
326
|
+
comment = (
|
|
327
|
+
f"# measured ≈ {best:.2f} via init-ontology"
|
|
328
|
+
f" (runner-up: {runner_name} at {runner:.2f}) — ratchet down over time"
|
|
329
|
+
)
|
|
330
|
+
lines.append(f" expected_pattern: {best_name}")
|
|
331
|
+
lines.append(f" profile: {profile}{profile_suffix}")
|
|
332
|
+
lines.append(f" drift_tolerance: {tolerance:.2f} {comment}")
|
|
333
|
+
|
|
334
|
+
# Emit hygiene_baseline for any measured value that breaches the GLOBAL hygiene bound
|
|
335
|
+
# (spec §2.2, #176/#170 task 4). Applies to BOTH labeled and hygiene-only entries.
|
|
336
|
+
baseline_items = _baseline_lines(fp)
|
|
337
|
+
if baseline_items:
|
|
338
|
+
lines.append(" hygiene_baseline:")
|
|
339
|
+
lines.extend(baseline_items)
|
|
340
|
+
|
|
341
|
+
return "\n".join(lines)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _assemble_yaml(entries: list[str]) -> str:
|
|
345
|
+
"""Concatenate the bundled header, generated project_domains, and skeleton comment."""
|
|
346
|
+
domain_block = "project_domains:\n" + "\n".join(entries) + "\n"
|
|
347
|
+
return _DEFAULT_ONTOLOGY_HEADER + domain_block + "\n" + _PROJECT_LEVEL_SKELETON
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
# Public API
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def discover_domains(nodes: list[Node], depth: int | None = None) -> list[str]:
|
|
356
|
+
"""Candidate domain prefixes from node FQNs (spec §2.1).
|
|
357
|
+
|
|
358
|
+
Auto-descent: walk down from the FQN roots while a level has a single
|
|
359
|
+
child; the first level with >= 2 children yields the candidates. An
|
|
360
|
+
explicit ``depth`` (segment count, >= 1) overrides auto-descent. Virtual
|
|
361
|
+
boundary nodes are excluded. Sorted, deduplicated.
|
|
362
|
+
|
|
363
|
+
A never-branching lineage yields its deepest id as the single candidate;
|
|
364
|
+
downstream min_nodes filtering keeps such micro-domains hygiene-only.
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
ValueError: if ``depth`` is not ``None`` and is <= 0.
|
|
368
|
+
"""
|
|
369
|
+
real_ids = [n.id for n in nodes if n.file_path != VIRTUAL_FILE_PATH]
|
|
370
|
+
if not real_ids:
|
|
371
|
+
return []
|
|
372
|
+
if depth is not None:
|
|
373
|
+
if depth <= 0:
|
|
374
|
+
msg = "depth must be a positive integer"
|
|
375
|
+
raise ValueError(msg)
|
|
376
|
+
return sorted(
|
|
377
|
+
{".".join(i.split(".")[:depth]) for i in real_ids if i.count(".") >= depth - 1}
|
|
378
|
+
)
|
|
379
|
+
prefix = ""
|
|
380
|
+
while True:
|
|
381
|
+
children = {
|
|
382
|
+
i[len(prefix) :].split(".")[0]
|
|
383
|
+
for i in real_ids
|
|
384
|
+
if i.startswith(prefix) and len(i) > len(prefix)
|
|
385
|
+
}
|
|
386
|
+
if not children:
|
|
387
|
+
# Never-branching lineage: the deepest id on this chain is the
|
|
388
|
+
# single candidate (downstream min_nodes will hygiene-fy it).
|
|
389
|
+
stub = prefix.removesuffix(".")
|
|
390
|
+
return [stub] if stub in set(real_ids) else []
|
|
391
|
+
if len(children) != 1:
|
|
392
|
+
break
|
|
393
|
+
prefix = f"{prefix}{next(iter(children))}."
|
|
394
|
+
return sorted({f"{prefix}{seg}" for seg in children})
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def propose_ontology(
|
|
398
|
+
db_path: str,
|
|
399
|
+
margin: float = 0.03,
|
|
400
|
+
min_nodes: int = 10,
|
|
401
|
+
depth: int | None = None,
|
|
402
|
+
) -> str:
|
|
403
|
+
"""Return a ready-to-edit patterns.yaml proposed from the measured graph.
|
|
404
|
+
|
|
405
|
+
Measure-then-label: discover candidate domain prefixes via ``discover_domains``,
|
|
406
|
+
fingerprint each with ``FingerprintExtractor``, fit against the five bundled
|
|
407
|
+
templates using ``DriftScorer`` (zero new fitting math), and emit YAML whose
|
|
408
|
+
``drift_tolerance`` values equal the measured score plus ``margin`` — green by
|
|
409
|
+
construction when fed back to ``analyze_drift`` on the same graph.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
db_path: Path to the SQLite graph database produced by ``cgis ingest``.
|
|
413
|
+
margin: Headroom added to every measured score when computing
|
|
414
|
+
``drift_tolerance`` (default 0.03).
|
|
415
|
+
min_nodes: Domains with fewer real nodes than this threshold are
|
|
416
|
+
emitted as hygiene-only (no ``expected_pattern``).
|
|
417
|
+
depth: Fixed FQN segment depth for domain discovery; ``None`` triggers
|
|
418
|
+
auto-descent.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
YAML text — bundled header (profiles/patterns/hygiene) followed by
|
|
422
|
+
a generated ``project_domains:`` block.
|
|
423
|
+
|
|
424
|
+
Raises:
|
|
425
|
+
FileNotFoundError: if ``db_path`` does not exist (SQLite would
|
|
426
|
+
otherwise silently create an empty database).
|
|
427
|
+
"""
|
|
428
|
+
if not Path(db_path).is_file():
|
|
429
|
+
msg = f"Graph database not found: {db_path}. Run `cgis ingest` first."
|
|
430
|
+
raise FileNotFoundError(msg)
|
|
431
|
+
|
|
432
|
+
# Write the bundled header to a temp file so DriftScorer can load it
|
|
433
|
+
# (its constructor strictly requires a path; we keep its surface unchanged).
|
|
434
|
+
# delete=False: the file persists after the context manager closes it so
|
|
435
|
+
# DriftScorer can read it; we unlink it explicitly in the finally block.
|
|
436
|
+
with tempfile.NamedTemporaryFile(
|
|
437
|
+
mode="w", suffix=".yaml", delete=False, encoding="utf-8"
|
|
438
|
+
) as header_fd:
|
|
439
|
+
header_fd.write(_DEFAULT_ONTOLOGY_HEADER)
|
|
440
|
+
header_path = header_fd.name
|
|
441
|
+
try:
|
|
442
|
+
scorer = DriftScorer(header_path)
|
|
443
|
+
|
|
444
|
+
with SQLiteStore(db_path) as store:
|
|
445
|
+
all_nodes = store.get_all_nodes()
|
|
446
|
+
extractor = FingerprintExtractor(store)
|
|
447
|
+
|
|
448
|
+
prefixes = discover_domains(all_nodes, depth=depth)
|
|
449
|
+
|
|
450
|
+
# Detect name collisions: short names (last segment) that appear
|
|
451
|
+
# more than once across prefixes must use the full prefix as name:.
|
|
452
|
+
short_names = [p.rsplit(".", maxsplit=1)[-1] for p in prefixes]
|
|
453
|
+
name_counts = Counter(short_names)
|
|
454
|
+
|
|
455
|
+
entries = [
|
|
456
|
+
_domain_entry(
|
|
457
|
+
prefix,
|
|
458
|
+
all_nodes,
|
|
459
|
+
extractor,
|
|
460
|
+
scorer,
|
|
461
|
+
margin,
|
|
462
|
+
min_nodes,
|
|
463
|
+
name_is_unique=(name_counts[prefix.rsplit(".", maxsplit=1)[-1]] == 1),
|
|
464
|
+
)
|
|
465
|
+
for prefix in prefixes
|
|
466
|
+
]
|
|
467
|
+
finally:
|
|
468
|
+
Path(header_path).unlink(missing_ok=True)
|
|
469
|
+
|
|
470
|
+
return _assemble_yaml(entries)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Domain-quotient graph: collapse each domain to one node (spec §3.4).
|
|
2
|
+
|
|
3
|
+
The quotient is scored by the SAME FingerprintExtractor + DriftScorer as
|
|
4
|
+
module-level domains — that closure under coarsening is the point, not an
|
|
5
|
+
implementation convenience.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections import Counter
|
|
9
|
+
|
|
10
|
+
from cgis.core.models import Edge, EdgeType, Node, NodeType
|
|
11
|
+
from cgis.query.drift.drift import DomainConfig
|
|
12
|
+
|
|
13
|
+
#: FQN prefix of quotient nodes; the project_level binding matches it.
|
|
14
|
+
QUOTIENT_PREFIX = "quotient"
|
|
15
|
+
|
|
16
|
+
_QUOTIENT_EDGE_TYPES = frozenset({EdgeType.IMPORTS, EdgeType.CALLS})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_quotient(
|
|
20
|
+
nodes: list[Node], edges: list[Edge], domains: list[DomainConfig]
|
|
21
|
+
) -> tuple[list[Node], list[Edge]]:
|
|
22
|
+
"""Return (quotient_nodes, quotient_edges) for the given domain bindings.
|
|
23
|
+
|
|
24
|
+
One MODULE node per domain (id = quotient.<name>); cross-domain IMPORTS
|
|
25
|
+
and CALLS edges aggregate per (source domain, target domain, type) with
|
|
26
|
+
weight = aggregated edge count. Intra-domain edges and edges touching
|
|
27
|
+
nodes outside every domain are dropped (raw_call targets land here, so
|
|
28
|
+
the quotient's unresolved_ratio is 0 for the observe-only milestone —
|
|
29
|
+
recorded in tests/self_parsing/test_drift.py).
|
|
30
|
+
"""
|
|
31
|
+
# Longest-prefix match: if one domain's prefix nests inside another's,
|
|
32
|
+
# the most specific binding wins regardless of declaration order.
|
|
33
|
+
by_specificity = sorted(domains, key=lambda d: len(d.fqn_prefix), reverse=True)
|
|
34
|
+
domain_of: dict[str, str] = {}
|
|
35
|
+
for n in nodes:
|
|
36
|
+
for d in by_specificity:
|
|
37
|
+
if n.id == d.fqn_prefix or n.id.startswith(d.fqn_prefix + "."):
|
|
38
|
+
domain_of[n.id] = d.name
|
|
39
|
+
break
|
|
40
|
+
|
|
41
|
+
qnodes = [
|
|
42
|
+
Node(
|
|
43
|
+
id=f"{QUOTIENT_PREFIX}.{d.name}",
|
|
44
|
+
type=NodeType.MODULE,
|
|
45
|
+
name=d.name,
|
|
46
|
+
file_path=d.fqn_prefix,
|
|
47
|
+
start_line=0,
|
|
48
|
+
end_line=0,
|
|
49
|
+
)
|
|
50
|
+
for d in domains
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
counts: Counter[tuple[str, str, EdgeType]] = Counter(
|
|
54
|
+
(domain_of[e.source], domain_of[e.target], e.type)
|
|
55
|
+
for e in edges
|
|
56
|
+
if e.type in _QUOTIENT_EDGE_TYPES
|
|
57
|
+
and e.source in domain_of
|
|
58
|
+
and e.target in domain_of
|
|
59
|
+
and domain_of[e.source] != domain_of[e.target]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
qedges = [
|
|
63
|
+
Edge(
|
|
64
|
+
id=f"{QUOTIENT_PREFIX}.{src}:{etype.value}:{QUOTIENT_PREFIX}.{dst}",
|
|
65
|
+
source=f"{QUOTIENT_PREFIX}.{src}",
|
|
66
|
+
target=f"{QUOTIENT_PREFIX}.{dst}",
|
|
67
|
+
type=etype,
|
|
68
|
+
weight=float(count),
|
|
69
|
+
confidence=1.0,
|
|
70
|
+
)
|
|
71
|
+
for (src, dst, etype), count in sorted(
|
|
72
|
+
counts.items(), key=lambda kv: (kv[0][0], kv[0][1], kv[0][2].value)
|
|
73
|
+
)
|
|
74
|
+
]
|
|
75
|
+
return qnodes, qedges
|