codegraph-ir 0.2.0__py3-none-any.whl → 0.3.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.
- cgir/__init__.py +1 -1
- cgir/analyses/cfg.py +15 -1
- cgir/analyses/coverage_link.py +152 -0
- cgir/analyses/effects.py +49 -15
- cgir/analyses/symbols.py +46 -1
- cgir/api/lsp_server.py +168 -0
- cgir/api/mcp_server.py +10 -14
- cgir/cli.py +38 -1
- cgir/hooks.py +4 -1
- cgir/ir/component_spec.py +3 -0
- cgir/languages/base.py +16 -0
- cgir/languages/go.py +24 -11
- cgir/languages/python.py +82 -28
- cgir/languages/typescript.py +45 -16
- cgir/pipeline.py +40 -2
- cgir/report/diff.py +29 -1
- cgir/report/lint.py +90 -0
- cgir/report/pack.py +6 -1
- cgir/report/search.py +182 -0
- cgir/slicing/slicer.py +8 -1
- cgir/sources/tree_sitter_source.py +20 -1
- {codegraph_ir-0.2.0.dist-info → codegraph_ir-0.3.0.dist-info}/METADATA +7 -1
- {codegraph_ir-0.2.0.dist-info → codegraph_ir-0.3.0.dist-info}/RECORD +26 -23
- {codegraph_ir-0.2.0.dist-info → codegraph_ir-0.3.0.dist-info}/WHEEL +0 -0
- {codegraph_ir-0.2.0.dist-info → codegraph_ir-0.3.0.dist-info}/entry_points.txt +0 -0
- {codegraph_ir-0.2.0.dist-info → codegraph_ir-0.3.0.dist-info}/licenses/LICENSE +0 -0
cgir/__init__.py
CHANGED
cgir/analyses/cfg.py
CHANGED
|
@@ -79,7 +79,13 @@ def build(graph: RepoGraph, repo_path: Path, adapter: LanguageAdapter | None = N
|
|
|
79
79
|
body = file_adapter.function_body(func_ts)
|
|
80
80
|
if body is None:
|
|
81
81
|
continue
|
|
82
|
-
builder = _CFGBuilder(
|
|
82
|
+
builder = _CFGBuilder(
|
|
83
|
+
graph=graph,
|
|
84
|
+
owner=func,
|
|
85
|
+
source=source,
|
|
86
|
+
adapter=file_adapter,
|
|
87
|
+
global_names=file_adapter.global_declared_names(func_ts, source),
|
|
88
|
+
)
|
|
83
89
|
builder.build_block(body, predecessors=[func.id], controller=None)
|
|
84
90
|
|
|
85
91
|
|
|
@@ -89,6 +95,9 @@ class _CFGBuilder:
|
|
|
89
95
|
owner: Node
|
|
90
96
|
source: bytes
|
|
91
97
|
adapter: LanguageAdapter
|
|
98
|
+
# names declared global/nonlocal in this function: writes to them mutate
|
|
99
|
+
# outer state and are recorded as `mutates`, not local `writes`.
|
|
100
|
+
global_names: set[str] = field(default_factory=set)
|
|
92
101
|
_counter: int = field(default=0, init=False)
|
|
93
102
|
|
|
94
103
|
def build_block(
|
|
@@ -102,6 +111,11 @@ class _CFGBuilder:
|
|
|
102
111
|
|
|
103
112
|
def build_stmt(self, ts_node: TSNode, preds: list[str], controller: str | None) -> list[str]:
|
|
104
113
|
desc = self.adapter.describe_statement(ts_node, self.source)
|
|
114
|
+
if isinstance(desc, AssignDesc) and self.global_names:
|
|
115
|
+
outer = [w for w in desc.writes if w in self.global_names]
|
|
116
|
+
if outer:
|
|
117
|
+
desc.writes = [w for w in desc.writes if w not in self.global_names]
|
|
118
|
+
desc.mutates = list(desc.mutates) + outer
|
|
105
119
|
if isinstance(desc, BranchDesc):
|
|
106
120
|
return self._build_branch(ts_node, desc, preds, controller)
|
|
107
121
|
if isinstance(desc, LoopDesc):
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Coverage-grounded test linkage — measured ``covered_by``, not inferred.
|
|
2
|
+
|
|
3
|
+
Static call-edge linkage says a test *statically references* a component;
|
|
4
|
+
coverage contexts say a test *actually executed its lines*. When per-test
|
|
5
|
+
contexts exist (pytest-cov ``--cov-context=test`` or coverage.py's
|
|
6
|
+
``dynamic_context = test_function``), the pipeline maps covered lines onto
|
|
7
|
+
component spans and unions the result with static linkage: coverage adds
|
|
8
|
+
tests reached through indirection (fixtures, integration paths); static
|
|
9
|
+
keeps tests the coverage run skipped.
|
|
10
|
+
|
|
11
|
+
Sources, in preference order:
|
|
12
|
+
* ``.coverage`` — coverage.py's SQLite store, read via stdlib sqlite3. The
|
|
13
|
+
line data is a "numbits" blob (bit N set = line N covered); the format is
|
|
14
|
+
internal to coverage.py but has been stable for years — decode failures
|
|
15
|
+
degrade gracefully to no coverage linkage.
|
|
16
|
+
* ``coverage.json`` — ``coverage json --show-contexts`` output.
|
|
17
|
+
|
|
18
|
+
No new dependency; no coverage data → identical behavior to before.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import sqlite3
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# rel_path -> normalized test id -> covered line numbers
|
|
28
|
+
CoverageData = dict[str, dict[str, set[int]]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def numbits_to_lines(blob: bytes) -> set[int]:
|
|
32
|
+
"""Decode coverage.py's numbits: bit N set means line N covered."""
|
|
33
|
+
lines: set[int] = set()
|
|
34
|
+
for byte_index, byte in enumerate(blob):
|
|
35
|
+
for bit in range(8):
|
|
36
|
+
if byte & (1 << bit):
|
|
37
|
+
lines.add(byte_index * 8 + bit)
|
|
38
|
+
return lines
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_context(context: str) -> str | None:
|
|
42
|
+
"""A coverage context name as a CGIR component id, or None to skip.
|
|
43
|
+
|
|
44
|
+
pytest-cov: ``tests/test_m.py::TestX::test_f|run`` → ``tests.test_m.TestX.test_f``
|
|
45
|
+
dynamic_context=test_function: already dotted. The empty string is the
|
|
46
|
+
global (non-test) context.
|
|
47
|
+
"""
|
|
48
|
+
if not context:
|
|
49
|
+
return None
|
|
50
|
+
context = context.split("|", 1)[0].split(" (", 1)[0].strip()
|
|
51
|
+
if "::" in context:
|
|
52
|
+
path, _, rest = context.partition("::")
|
|
53
|
+
module = path.removesuffix(".py").replace("/", ".").replace("\\", ".")
|
|
54
|
+
return f"{module}.{rest.replace('::', '.')}"
|
|
55
|
+
return context
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_coverage_contexts(repo: Path) -> CoverageData | None:
|
|
59
|
+
"""Per-test line coverage from ``.coverage`` or ``coverage.json``."""
|
|
60
|
+
dot = repo / ".coverage"
|
|
61
|
+
if dot.exists():
|
|
62
|
+
data = _read_sqlite(dot, repo)
|
|
63
|
+
if data:
|
|
64
|
+
return data
|
|
65
|
+
js = repo / "coverage.json"
|
|
66
|
+
if js.exists():
|
|
67
|
+
return _read_json(js)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_sqlite(path: Path, repo: Path) -> CoverageData | None:
|
|
72
|
+
try:
|
|
73
|
+
db = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
74
|
+
try:
|
|
75
|
+
files = dict(db.execute("SELECT id, path FROM file"))
|
|
76
|
+
contexts = dict(db.execute("SELECT id, context FROM context"))
|
|
77
|
+
rows = list(db.execute("SELECT file_id, context_id, numbits FROM line_bits"))
|
|
78
|
+
finally:
|
|
79
|
+
db.close()
|
|
80
|
+
except sqlite3.Error:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
out: CoverageData = {}
|
|
84
|
+
for file_id, context_id, numbits in rows:
|
|
85
|
+
test_id = normalize_context(str(contexts.get(context_id, "")))
|
|
86
|
+
if test_id is None:
|
|
87
|
+
continue
|
|
88
|
+
rel = _relativize(str(files.get(file_id, "")), repo)
|
|
89
|
+
if rel is None:
|
|
90
|
+
continue
|
|
91
|
+
lines = numbits_to_lines(numbits)
|
|
92
|
+
if lines:
|
|
93
|
+
out.setdefault(rel, {}).setdefault(test_id, set()).update(lines)
|
|
94
|
+
return out or None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_json(path: Path) -> CoverageData | None:
|
|
98
|
+
try:
|
|
99
|
+
payload = json.loads(path.read_text())
|
|
100
|
+
except (OSError, json.JSONDecodeError):
|
|
101
|
+
return None
|
|
102
|
+
out: CoverageData = {}
|
|
103
|
+
for file_path, file_data in (payload.get("files") or {}).items():
|
|
104
|
+
contexts = file_data.get("contexts") or {}
|
|
105
|
+
for line_str, ctx_names in contexts.items():
|
|
106
|
+
try:
|
|
107
|
+
line = int(line_str)
|
|
108
|
+
except ValueError:
|
|
109
|
+
continue
|
|
110
|
+
for name in ctx_names:
|
|
111
|
+
test_id = normalize_context(str(name))
|
|
112
|
+
if test_id is not None:
|
|
113
|
+
out.setdefault(file_path, {}).setdefault(test_id, set()).add(line)
|
|
114
|
+
return out or None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _relativize(file_path: str, repo: Path) -> str | None:
|
|
118
|
+
try:
|
|
119
|
+
return str(Path(file_path).resolve().relative_to(repo.resolve()))
|
|
120
|
+
except ValueError:
|
|
121
|
+
return file_path if file_path and not Path(file_path).is_absolute() else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def coverage_covered_by(
|
|
125
|
+
cov: CoverageData, spans: list[tuple[str, str, int, int]]
|
|
126
|
+
) -> dict[str, set[str]]:
|
|
127
|
+
"""Map coverage data onto component spans.
|
|
128
|
+
|
|
129
|
+
``spans`` is ``(component_id, rel_path, start_line, end_line)``. A test
|
|
130
|
+
covers a component when any of its covered lines falls inside the span.
|
|
131
|
+
A test never covers itself (its own body's lines are its execution).
|
|
132
|
+
"""
|
|
133
|
+
out: dict[str, set[str]] = {}
|
|
134
|
+
for component_id, rel_path, start, end in spans:
|
|
135
|
+
per_test = cov.get(rel_path)
|
|
136
|
+
if not per_test:
|
|
137
|
+
continue
|
|
138
|
+
for test_id, lines in per_test.items():
|
|
139
|
+
if test_id == component_id:
|
|
140
|
+
continue
|
|
141
|
+
if any(start <= line <= end for line in lines):
|
|
142
|
+
out.setdefault(component_id, set()).add(test_id)
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = [
|
|
147
|
+
"CoverageData",
|
|
148
|
+
"coverage_covered_by",
|
|
149
|
+
"normalize_context",
|
|
150
|
+
"numbits_to_lines",
|
|
151
|
+
"read_coverage_contexts",
|
|
152
|
+
]
|
cgir/analyses/effects.py
CHANGED
|
@@ -41,31 +41,63 @@ def classify(
|
|
|
41
41
|
graph: RepoGraph, repo_path: Path, adapter: LanguageAdapter | None = None
|
|
42
42
|
) -> dict[str, list[str]]:
|
|
43
43
|
"""Return ``{function_id: sorted([effect_tag, ...])}`` for every function/method."""
|
|
44
|
+
return classify_with_confidence(graph, repo_path, adapter)[0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def classify_with_confidence(
|
|
48
|
+
graph: RepoGraph, repo_path: Path, adapter: LanguageAdapter | None = None
|
|
49
|
+
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
|
50
|
+
"""``(effects, lexical_effects)`` — the second maps each function to the
|
|
51
|
+
subset of its tags backed only by *lexical* evidence (suffix / receiver-
|
|
52
|
+
name heuristics), the measured false-positive class. Gate rules treat
|
|
53
|
+
those as low-confidence by default.
|
|
54
|
+
|
|
55
|
+
``calls_effectful`` confidence follows the callees: it is high when any
|
|
56
|
+
reachable callee carries a high-confidence impure tag, lexical when the
|
|
57
|
+
entire reachable impurity is lexical.
|
|
58
|
+
"""
|
|
44
59
|
cache = SourceCache(repo_path, adapter)
|
|
45
60
|
func_nodes = [n for n in graph.nodes() if n.kind in {NodeKind.Function, NodeKind.Method}]
|
|
46
61
|
alias_maps = _module_alias_maps(graph)
|
|
47
62
|
|
|
48
|
-
|
|
63
|
+
conf: dict[str, dict[str, str]] = {}
|
|
49
64
|
for func in func_nodes:
|
|
50
65
|
module_id = module_of(graph, func)
|
|
51
66
|
aliases = alias_maps.get(module_id, {}) if module_id else {}
|
|
52
|
-
|
|
67
|
+
conf[func.id] = _direct_effects_confidence(cache, func, aliases)
|
|
53
68
|
|
|
54
|
-
#
|
|
55
|
-
#
|
|
69
|
+
# Two fixpoints over CALLS: does any reachable callee carry impurity at
|
|
70
|
+
# all, and is any of it high-confidence? Only *impure* effects taint
|
|
71
|
+
# callers — raise-only callees don't.
|
|
72
|
+
reaches_any: set[str] = set()
|
|
73
|
+
reaches_high: set[str] = set()
|
|
56
74
|
changed = True
|
|
57
75
|
while changed:
|
|
58
76
|
changed = False
|
|
59
77
|
for func in func_nodes:
|
|
60
78
|
for edge in graph.out_edges(func.id, EdgeKind.CALLS):
|
|
61
|
-
callee =
|
|
62
|
-
if
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
callee = conf.get(edge.dst, {})
|
|
80
|
+
impure = {t for t in callee if t in IMPURE_EFFECT_TAGS}
|
|
81
|
+
callee_any = bool(impure) or edge.dst in reaches_any
|
|
82
|
+
callee_high = any(callee[t] == "high" for t in impure) or edge.dst in reaches_high
|
|
83
|
+
if callee_any and func.id not in reaches_any:
|
|
84
|
+
reaches_any.add(func.id)
|
|
85
|
+
changed = True
|
|
86
|
+
if callee_high and func.id not in reaches_high:
|
|
87
|
+
reaches_high.add(func.id)
|
|
66
88
|
changed = True
|
|
67
89
|
|
|
68
|
-
|
|
90
|
+
effects: dict[str, list[str]] = {}
|
|
91
|
+
lexical: dict[str, list[str]] = {}
|
|
92
|
+
for func in func_nodes:
|
|
93
|
+
tags = dict(conf[func.id])
|
|
94
|
+
if func.id in reaches_any:
|
|
95
|
+
tags[TRANSITIVE_TAG] = "high" if func.id in reaches_high else "lexical"
|
|
96
|
+
effects[func.id] = sorted(tags)
|
|
97
|
+
low = sorted(t for t, c in tags.items() if c == "lexical")
|
|
98
|
+
if low:
|
|
99
|
+
lexical[func.id] = low
|
|
100
|
+
return effects, lexical
|
|
69
101
|
|
|
70
102
|
|
|
71
103
|
def _module_alias_maps(graph: RepoGraph) -> dict[str, dict[str, str]]:
|
|
@@ -82,14 +114,16 @@ def _module_alias_maps(graph: RepoGraph) -> dict[str, dict[str, str]]:
|
|
|
82
114
|
return maps
|
|
83
115
|
|
|
84
116
|
|
|
85
|
-
def
|
|
117
|
+
def _direct_effects_confidence(
|
|
118
|
+
cache: SourceCache, func: Node, aliases: dict[str, str]
|
|
119
|
+
) -> dict[str, str]:
|
|
86
120
|
if func.path is None or func.start_line is None:
|
|
87
|
-
return
|
|
121
|
+
return {}
|
|
88
122
|
parsed = cache.get(func.path)
|
|
89
123
|
if parsed is None:
|
|
90
|
-
return
|
|
124
|
+
return {}
|
|
91
125
|
source, root, adapter = parsed
|
|
92
126
|
func_ts = adapter.locate_function(root, func.name, func.start_line - 1)
|
|
93
127
|
if func_ts is None:
|
|
94
|
-
return
|
|
95
|
-
return adapter.
|
|
128
|
+
return {}
|
|
129
|
+
return adapter.direct_effects_confidence(func_ts, source, aliases)
|
cgir/analyses/symbols.py
CHANGED
|
@@ -42,18 +42,63 @@ def build_symbol_tables(graph: RepoGraph) -> dict[str, SymbolTable]:
|
|
|
42
42
|
tables[module.id] = table
|
|
43
43
|
|
|
44
44
|
_merge_go_packages(graph, tables)
|
|
45
|
+
go_dirs = _go_package_dirs(graph)
|
|
46
|
+
go_module_prefix = _go_module_prefix(graph)
|
|
45
47
|
|
|
46
48
|
for module in graph.nodes(NodeKind.Module):
|
|
47
49
|
table = tables[module.id]
|
|
50
|
+
is_go = module.attrs.get("language") == "go"
|
|
48
51
|
for child in graph.children(module.id, NodeKind.Import):
|
|
49
52
|
target = str(child.attrs.get("target") or child.name)
|
|
50
53
|
alias = child.attrs.get("alias")
|
|
51
54
|
local = alias if isinstance(alias, str) else target.rsplit(".", 1)[-1]
|
|
52
|
-
|
|
55
|
+
resolved = _resolve_go_package(target, go_dirs, go_module_prefix) if is_go else None
|
|
56
|
+
table.bindings[local] = resolved or _resolve_target(qualname_index, target)
|
|
53
57
|
|
|
54
58
|
return tables
|
|
55
59
|
|
|
56
60
|
|
|
61
|
+
def _go_module_prefix(graph: RepoGraph) -> str | None:
|
|
62
|
+
"""The go.mod ``module`` directive (dotted), stashed on the Repository node."""
|
|
63
|
+
for repo in graph.nodes(NodeKind.Repository):
|
|
64
|
+
gomod = repo.attrs.get("go_module")
|
|
65
|
+
if isinstance(gomod, str) and gomod:
|
|
66
|
+
return gomod.replace("/", ".")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _go_package_dirs(graph: RepoGraph) -> dict[str, list[str]]:
|
|
71
|
+
"""Dotted package directory -> module node ids of the go files in it."""
|
|
72
|
+
dirs: dict[str, list[str]] = {}
|
|
73
|
+
for module in graph.nodes(NodeKind.Module):
|
|
74
|
+
if module.attrs.get("language") != "go" or not module.path:
|
|
75
|
+
continue
|
|
76
|
+
directory = module.path.rsplit("/", 1)[0] if "/" in module.path else ""
|
|
77
|
+
dirs.setdefault(directory.replace("/", "."), []).append(module.id)
|
|
78
|
+
return {d: sorted(ids) for d, ids in dirs.items()}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_go_package(
|
|
82
|
+
target: str, go_dirs: dict[str, list[str]], module_prefix: str | None
|
|
83
|
+
) -> str | None:
|
|
84
|
+
"""Resolve a Go import path to a package directory's module.
|
|
85
|
+
|
|
86
|
+
``import "example.com/myapp/internal/store"`` → strip the go.mod module
|
|
87
|
+
prefix → ``internal.store`` → bind to a module in that directory (the
|
|
88
|
+
directory merge exposes every package member through it). Without a
|
|
89
|
+
go.mod, fall back to a *unique* directory-suffix match — ambiguity stays
|
|
90
|
+
unresolved rather than guessed.
|
|
91
|
+
"""
|
|
92
|
+
if module_prefix and target.startswith(module_prefix + "."):
|
|
93
|
+
rel = target[len(module_prefix) + 1 :]
|
|
94
|
+
modules = go_dirs.get(rel)
|
|
95
|
+
return modules[0] if modules else None
|
|
96
|
+
matches = [d for d in go_dirs if d and (target == d or target.endswith("." + d))]
|
|
97
|
+
if len(matches) == 1:
|
|
98
|
+
return go_dirs[matches[0]][0]
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
57
102
|
def _merge_go_packages(graph: RepoGraph, tables: dict[str, SymbolTable]) -> None:
|
|
58
103
|
"""Go's package scope: files in one directory share top-level names.
|
|
59
104
|
|
cgir/api/lsp_server.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""LSP diagnostics — live contract drift as editor squiggles.
|
|
2
|
+
|
|
3
|
+
Diagnostics-only language server (no completion, no hover): on every save
|
|
4
|
+
it rescans the repo, and publishes
|
|
5
|
+
|
|
6
|
+
* **errors** for pin violations (`# cgir: pure` that isn't) — absolute
|
|
7
|
+
invariants, visible from the first refresh;
|
|
8
|
+
* **warnings** for gate drift vs the previous scan (the default low-noise
|
|
9
|
+
rule set: effect gain/loss on net/fs/db, always-on change pins).
|
|
10
|
+
|
|
11
|
+
The pure core (:func:`compute_diagnostics`, :class:`DiagnosticsEngine`) is
|
|
12
|
+
fully testable offline; pygls wiring hides behind the ``cgir[lsp]`` extra
|
|
13
|
+
(lazy import — same pattern as mcp/llm). A useful side effect: the engine
|
|
14
|
+
writes the ``.cgir`` index on every refresh, so MCP/pack/impact stay fresh
|
|
15
|
+
while you edit.
|
|
16
|
+
|
|
17
|
+
Run: ``cgir lsp`` (stdio). Editor config in ``docs/lsp.md``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from cgir.hooks import DEFAULT_FAIL_ON
|
|
27
|
+
from cgir.ir.component_spec import ComponentSpec
|
|
28
|
+
from cgir.pipeline import scan_repo
|
|
29
|
+
from cgir.report.diff import compute_diff, violations
|
|
30
|
+
from cgir.report.pins import change_violations, state_violations
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class Diagnostic:
|
|
35
|
+
path: str # repo-relative
|
|
36
|
+
line: int # 1-based
|
|
37
|
+
severity: str # "error" | "warning"
|
|
38
|
+
message: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _locate(specs: list[ComponentSpec]) -> dict[str, tuple[str, int]]:
|
|
42
|
+
out: dict[str, tuple[str, int]] = {}
|
|
43
|
+
for spec in specs:
|
|
44
|
+
if spec.trace:
|
|
45
|
+
path, _, line = spec.trace[0].rpartition(":")
|
|
46
|
+
try:
|
|
47
|
+
out[spec.id] = (path, int(line))
|
|
48
|
+
except ValueError:
|
|
49
|
+
continue
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _place(
|
|
54
|
+
lines: list[str], located: dict[str, tuple[str, int]], severity: str
|
|
55
|
+
) -> list[Diagnostic]:
|
|
56
|
+
diags: list[Diagnostic] = []
|
|
57
|
+
for entry in lines:
|
|
58
|
+
spec_id, _, message = entry.partition(": ")
|
|
59
|
+
hit = located.get(spec_id)
|
|
60
|
+
if hit is None or not message:
|
|
61
|
+
continue # can't be placed in a file — skip rather than guess
|
|
62
|
+
path, line = hit
|
|
63
|
+
diags.append(Diagnostic(path=path, line=line, severity=severity, message=message))
|
|
64
|
+
return diags
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def compute_diagnostics(
|
|
68
|
+
old_specs: list[ComponentSpec],
|
|
69
|
+
new_specs: list[ComponentSpec],
|
|
70
|
+
rules: tuple[str, ...] = DEFAULT_FAIL_ON,
|
|
71
|
+
) -> list[Diagnostic]:
|
|
72
|
+
"""Pin violations (errors) + gate drift vs the previous scan (warnings)."""
|
|
73
|
+
located = _locate(new_specs)
|
|
74
|
+
diags = _place(state_violations(new_specs), located, "error")
|
|
75
|
+
diags += _place(change_violations(old_specs, new_specs), located, "error")
|
|
76
|
+
if old_specs:
|
|
77
|
+
diff = compute_diff(old_specs, new_specs)
|
|
78
|
+
diags += _place(violations(diff, list(rules)), located, "warning")
|
|
79
|
+
return diags
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DiagnosticsEngine:
|
|
83
|
+
"""Rescan-on-save loop: keeps the previous scan for drift comparison."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, repo: Path, index_dir: Path | None = None) -> None:
|
|
86
|
+
self._repo = repo
|
|
87
|
+
self._index_dir = index_dir or (repo / ".cgir")
|
|
88
|
+
self._prev: list[ComponentSpec] = []
|
|
89
|
+
self._primed = False
|
|
90
|
+
|
|
91
|
+
def refresh(self) -> list[Diagnostic]:
|
|
92
|
+
result = scan_repo(self._repo, out=self._index_dir)
|
|
93
|
+
old = self._prev if self._primed else []
|
|
94
|
+
diags = compute_diagnostics(old, result.specs)
|
|
95
|
+
self._prev = result.specs
|
|
96
|
+
self._primed = True
|
|
97
|
+
return diags
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def create_server() -> Any:
|
|
101
|
+
"""Build the pygls server (requires the ``cgir[lsp]`` extra)."""
|
|
102
|
+
try:
|
|
103
|
+
from lsprotocol import types as lsp
|
|
104
|
+
|
|
105
|
+
try: # pygls 2.x
|
|
106
|
+
from pygls.lsp.server import LanguageServer
|
|
107
|
+
except ImportError: # pygls 1.x
|
|
108
|
+
from pygls.server import LanguageServer # type: ignore[attr-defined,no-redef]
|
|
109
|
+
except ImportError as exc:
|
|
110
|
+
raise RuntimeError(
|
|
111
|
+
"Install cgir[lsp] to run the language server (adds the pygls package)"
|
|
112
|
+
) from exc
|
|
113
|
+
|
|
114
|
+
server = LanguageServer("cgir", "0")
|
|
115
|
+
state: dict[str, Any] = {"engine": None, "published": set()}
|
|
116
|
+
|
|
117
|
+
_SEVERITY = {
|
|
118
|
+
"error": lsp.DiagnosticSeverity.Error,
|
|
119
|
+
"warning": lsp.DiagnosticSeverity.Warning,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
def _publish(diags: list[Diagnostic]) -> None:
|
|
123
|
+
engine: DiagnosticsEngine = state["engine"]
|
|
124
|
+
by_path: dict[str, list[Diagnostic]] = {}
|
|
125
|
+
for d in diags:
|
|
126
|
+
by_path.setdefault(d.path, []).append(d)
|
|
127
|
+
current = set(by_path)
|
|
128
|
+
for rel in current | state["published"]:
|
|
129
|
+
uri = (engine._repo / rel).resolve().as_uri()
|
|
130
|
+
items = [
|
|
131
|
+
lsp.Diagnostic(
|
|
132
|
+
range=lsp.Range(
|
|
133
|
+
start=lsp.Position(line=max(d.line - 1, 0), character=0),
|
|
134
|
+
end=lsp.Position(line=max(d.line - 1, 0), character=200),
|
|
135
|
+
),
|
|
136
|
+
message=d.message,
|
|
137
|
+
severity=_SEVERITY[d.severity],
|
|
138
|
+
source="cgir",
|
|
139
|
+
)
|
|
140
|
+
for d in by_path.get(rel, [])
|
|
141
|
+
]
|
|
142
|
+
_send(uri, items)
|
|
143
|
+
state["published"] = current
|
|
144
|
+
|
|
145
|
+
def _send(uri: str, items: list[Any]) -> None:
|
|
146
|
+
if hasattr(server, "publish_diagnostics"): # pygls 1.x
|
|
147
|
+
server.publish_diagnostics(uri, items)
|
|
148
|
+
else: # pygls 2.x
|
|
149
|
+
server.text_document_publish_diagnostics(
|
|
150
|
+
lsp.PublishDiagnosticsParams(uri=uri, diagnostics=items)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@server.feature(lsp.INITIALIZED)
|
|
154
|
+
def _initialized(_params: Any) -> None:
|
|
155
|
+
root = server.workspace.root_path
|
|
156
|
+
if root:
|
|
157
|
+
state["engine"] = DiagnosticsEngine(Path(root))
|
|
158
|
+
_publish(state["engine"].refresh())
|
|
159
|
+
|
|
160
|
+
@server.feature(lsp.TEXT_DOCUMENT_DID_SAVE)
|
|
161
|
+
def _did_save(_params: Any) -> None:
|
|
162
|
+
if state["engine"] is not None:
|
|
163
|
+
_publish(state["engine"].refresh())
|
|
164
|
+
|
|
165
|
+
return server
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
__all__ = ["Diagnostic", "DiagnosticsEngine", "compute_diagnostics", "create_server"]
|
cgir/api/mcp_server.py
CHANGED
|
@@ -73,19 +73,12 @@ def tool_pack(index_dir: Path, component_id: str, budget: int = 4000) -> str:
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
def tool_search(index_dir: Path, query: str) -> str:
|
|
76
|
-
"""
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
line = f"{spec.id} [{spec.kind.value}]"
|
|
83
|
-
if spec.entrypoint:
|
|
84
|
-
line += f" ({spec.entrypoint})"
|
|
85
|
-
hits.append(line)
|
|
86
|
-
if not hits:
|
|
87
|
-
return f"no components match {query!r}"
|
|
88
|
-
return "\n".join(hits) + "\n"
|
|
76
|
+
"""Ranked search: free terms + contract predicates (kind:pure,
|
|
77
|
+
effects:net, lexical:false, callers:>3, pins:pure, entrypoint:HTTP,
|
|
78
|
+
lang:go, covered:false)."""
|
|
79
|
+
from cgir.report.search import render_search
|
|
80
|
+
|
|
81
|
+
return render_search(read_specs(index_dir), query)
|
|
89
82
|
|
|
90
83
|
|
|
91
84
|
def tool_entrypoints(index_dir: Path) -> str:
|
|
@@ -156,7 +149,10 @@ def create_server(index_dir: Path) -> Any:
|
|
|
156
149
|
|
|
157
150
|
@server.tool()
|
|
158
151
|
def search(query: str) -> str:
|
|
159
|
-
"""
|
|
152
|
+
"""Ranked component search. Free terms + contract predicates:
|
|
153
|
+
kind:pure | effects:net | effects:none | lexical:true/false |
|
|
154
|
+
callers:>3 | calls:>5 | pins:pure | pinned:true |
|
|
155
|
+
entrypoint:true/HTTP | lang:python/typescript/go | covered:false."""
|
|
160
156
|
return tool_search(index_dir, query)
|
|
161
157
|
|
|
162
158
|
@server.tool()
|
cgir/cli.py
CHANGED
|
@@ -197,6 +197,14 @@ _STARTER_CONFIG = """\
|
|
|
197
197
|
# name = "core stays pure"
|
|
198
198
|
# in = "app.core.*"
|
|
199
199
|
# forbid-effect = ["net", "db"]
|
|
200
|
+
#
|
|
201
|
+
# [[rule]]
|
|
202
|
+
# name = "no call cycles"
|
|
203
|
+
# forbid-cycle = true
|
|
204
|
+
#
|
|
205
|
+
# [[rule]]
|
|
206
|
+
# name = "layered"
|
|
207
|
+
# layers = ["app.api.*", "app.core.*", "app.db.*"]
|
|
200
208
|
"""
|
|
201
209
|
|
|
202
210
|
_NEXT_STEPS = """\
|
|
@@ -275,6 +283,19 @@ def watch(
|
|
|
275
283
|
typer.echo("\nstopped.")
|
|
276
284
|
|
|
277
285
|
|
|
286
|
+
@app.command()
|
|
287
|
+
def search(
|
|
288
|
+
query: Annotated[
|
|
289
|
+
str, typer.Argument(help="Free terms + predicates (kind:pure effects:net callers:>3 ...)")
|
|
290
|
+
],
|
|
291
|
+
index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Ranked component search over the index (contract predicates included)."""
|
|
294
|
+
from cgir.report.search import render_search
|
|
295
|
+
|
|
296
|
+
typer.echo(render_search(_load_specs(index_dir), query), nl=False)
|
|
297
|
+
|
|
298
|
+
|
|
278
299
|
@app.command()
|
|
279
300
|
def flow(
|
|
280
301
|
component_id: Annotated[str, typer.Argument(metavar="ID")],
|
|
@@ -402,7 +423,10 @@ def pack(
|
|
|
402
423
|
graph = _load_graph(index_dir) if (index_dir / "repo_graph.json").exists() else None
|
|
403
424
|
source = _component_source(graph, component_id, repo) if repo else None
|
|
404
425
|
types = _type_sources(graph, referenced_type_names(target), repo) if repo else {}
|
|
405
|
-
|
|
426
|
+
# Coverage-grounded linkage can attach dozens of tests to hot-path
|
|
427
|
+
# components; embed at most a handful of sources (impact --run still
|
|
428
|
+
# uses the full set — accuracy there, budget here).
|
|
429
|
+
tests = _test_sources(graph, target.covered_by[:_PACK_TEST_CAP], repo) if repo else {}
|
|
406
430
|
context = _module_context(graph, component_id, repo) if repo else {}
|
|
407
431
|
receivers = _call_receivers(graph, target)
|
|
408
432
|
bundle = build_pack(
|
|
@@ -419,6 +443,7 @@ def pack(
|
|
|
419
443
|
|
|
420
444
|
|
|
421
445
|
_HELPER_MAX_LINES = 25
|
|
446
|
+
_PACK_TEST_CAP = 5
|
|
422
447
|
|
|
423
448
|
|
|
424
449
|
def _module_context(
|
|
@@ -661,6 +686,18 @@ def mcp(
|
|
|
661
686
|
server.run()
|
|
662
687
|
|
|
663
688
|
|
|
689
|
+
@app.command()
|
|
690
|
+
def lsp() -> None:
|
|
691
|
+
"""Serve live contract diagnostics over LSP (stdio; requires cgir[lsp])."""
|
|
692
|
+
from cgir.api.lsp_server import create_server
|
|
693
|
+
|
|
694
|
+
try:
|
|
695
|
+
server = create_server()
|
|
696
|
+
except RuntimeError as exc:
|
|
697
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
698
|
+
server.start_io()
|
|
699
|
+
|
|
700
|
+
|
|
664
701
|
@app.command()
|
|
665
702
|
def diff(
|
|
666
703
|
old_index: Annotated[Path, typer.Argument(exists=True, file_okay=False)],
|
cgir/hooks.py
CHANGED
|
@@ -146,7 +146,10 @@ def render_hook(result: HookResult) -> str:
|
|
|
146
146
|
lines.append("")
|
|
147
147
|
lines.append(f" ❌ blocked — {len(result.violations)} drift violation(s):")
|
|
148
148
|
lines.extend(f" ! {v}" for v in result.violations)
|
|
149
|
-
lines.append(" (
|
|
149
|
+
lines.append(" (fix and re-stage — note the blocked change is still staged; discard it")
|
|
150
|
+
lines.append(
|
|
151
|
+
" with `git restore -S -W <file>` — or bypass once with `git commit --no-verify`)"
|
|
152
|
+
)
|
|
150
153
|
else:
|
|
151
154
|
lines.append(" ✅ no blocking contract drift")
|
|
152
155
|
return "\n".join(lines) + "\n"
|
cgir/ir/component_spec.py
CHANGED
|
@@ -42,6 +42,7 @@ COMPONENT_SPEC_SCHEMA: dict[str, Any] = {
|
|
|
42
42
|
"writes": {"type": "array", "items": {"type": "string"}},
|
|
43
43
|
"purity": {"type": "number", "minimum": 0, "maximum": 1},
|
|
44
44
|
"pins": {"type": "array", "items": {"type": "string"}},
|
|
45
|
+
"lexical_effects": {"type": "array", "items": {"type": "string"}},
|
|
45
46
|
"algorithm": {"type": "array", "items": {"type": "string"}},
|
|
46
47
|
"trace": {"type": "array", "items": {"type": "string"}},
|
|
47
48
|
},
|
|
@@ -77,6 +78,8 @@ class ComponentSpec:
|
|
|
77
78
|
writes: list[str] = field(default_factory=list)
|
|
78
79
|
purity: float | None = None
|
|
79
80
|
pins: list[str] = field(default_factory=list)
|
|
81
|
+
# effects backed only by lexical (suffix/receiver-name) evidence
|
|
82
|
+
lexical_effects: list[str] = field(default_factory=list)
|
|
80
83
|
algorithm: list[str] = field(default_factory=list)
|
|
81
84
|
|
|
82
85
|
def to_dict(self) -> dict[str, Any]:
|