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 CHANGED
@@ -3,4 +3,4 @@
3
3
  See ``Code-IR.md`` at the repo root for the product specification.
4
4
  """
5
5
 
6
- __version__ = "0.2.0"
6
+ __version__ = "0.3.0"
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(graph=graph, owner=func, source=source, adapter=file_adapter)
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
- effects: dict[str, set[str]] = {}
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
- effects[func.id] = _direct_effects(cache, func, aliases)
67
+ conf[func.id] = _direct_effects_confidence(cache, func, aliases)
53
68
 
54
- # Propagate transitively over CALLS edges until fixed point. Only
55
- # *impure* effects taint callers raise-only callees don't.
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 = effects.get(edge.dst, set())
62
- if (
63
- callee & IMPURE_EFFECT_TAGS or TRANSITIVE_TAG in callee
64
- ) and TRANSITIVE_TAG not in effects[func.id]:
65
- effects[func.id].add(TRANSITIVE_TAG)
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
- return {nid: sorted(tags) for nid, tags in effects.items()}
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 _direct_effects(cache: SourceCache, func: Node, aliases: dict[str, str]) -> set[str]:
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 set()
121
+ return {}
88
122
  parsed = cache.get(func.path)
89
123
  if parsed is None:
90
- return set()
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 set()
95
- return adapter.direct_effects(func_ts, source, aliases)
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
- table.bindings[local] = _resolve_target(qualname_index, target)
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
- """Components whose id, entrypoint, or effects match a substring."""
77
- needle = query.lower()
78
- hits: list[str] = []
79
- for spec in read_specs(index_dir):
80
- haystack = " ".join([spec.id, spec.entrypoint or "", " ".join(spec.effects)]).lower()
81
- if needle in haystack:
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
- """Find components by id / entrypoint / effect substring."""
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
- tests = _test_sources(graph, target.covered_by, repo) if repo else {}
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(" (bypass once with `git commit --no-verify`)")
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]: