gabriel / muse public
contract.py python
1,185 lines 45.4 KB
Raw
sha256:057be43e401106b2b4d877e8028ae67d16a966562d7429707b71a5eaacd5027b docs(mwp-master): tick all ACs green; mark MWP-7 complete w… Sonnet 4.6 20 days ago
1 """muse code contract — infer the implicit behavioral contract of a symbol.
2
3 Every function has two contracts:
4
5 The **explicit** contract — type annotations, docstrings, parameter names.
6 The **implicit** contract — what callers actually depend on, what tests
7 actually assert, and how the contract has evolved under real pressure.
8
9 ``muse code contract`` infers the implicit contract by mining four sources
10 that only Muse has simultaneous access to:
11
12 1. **Current signature** — parameters, type annotations, defaults, and return
13 type extracted from the AST.
14
15 2. **Call-site patterns** — how every direct caller in the production codebase
16 actually invokes the symbol: which keyword args are used, whether the return
17 value is stored, discarded, asserted, or returned, and what argument types
18 appear in practice.
19
20 3. **Test assertions** — ``assert`` statements extracted from every test
21 function that exercises this symbol, forming the ground-truth specification
22 of expected behavior.
23
24 4. **Commit history** — stability fingerprint (MAJOR/MINOR/PATCH bumps,
25 signature changes, body rewrites) plus commit messages that contain
26 contract signals like ``breaking:``, ``invariant:``, ``deprecated:``.
27
28 Combined, these four sources answer questions no single tool can answer alone:
29
30 * "Is this parameter actually required, or do callers always rely on the
31 default?"
32 * "Do callers check the return value, or do they silently discard it?"
33 * "What do tests actually guarantee about the return value?"
34 * "Has this function been stable for 200 commits, or is it changing every
35 week?"
36 * "Does any commit message hint at an implicit invariant?"
37
38 Usage::
39
40 muse code contract "billing.py::Invoice.compute_total"
41 muse code contract "muse/core/store.py::resolve_commit_ref"
42 muse code contract "billing.py::Invoice.compute_total" --max-commits 200
43 muse code contract "billing.py::Invoice.compute_total" --json
44
45 Output::
46
47 Contract: billing.py::Invoice.compute_total
48
49 Signature
50 def compute_total(items, currency='USD') -> float
51
52 Parameters
53 items positional always provided · list/comprehension at 11/12 sites
54 currency keyword omitted at 8/12 sites (default relied on)
55 observed values: 'USD' (8×) · 'EUR' (4×)
56
57 Return value
58 → stored at 9/12 call sites (75%)
59 → discarded at 2/12 call sites ⚠️ possible misuse
60 → returned at 1/12 call sites
61
62 Inferred preconditions
63 • items is always a list or list-comprehension
64 • currency is effectively optional — default 'USD' used by 67% of callers
65
66 Inferred postconditions
67 • Return value is expected to be meaningful (stored 75% of the time)
68 • 2 callers silently discard the return — possible side-effect assumption
69
70 Test assertions (3 test functions)
71 assert result == expected_total
72 assert result > 0
73 assert isinstance(result, (int, float))
74
75 Commit signals
76 MINOR "feat: add optional currency parameter" (Feb 03)
77 PATCH "perf: vectorise invoice computation" (Feb 14)
78
79 Stability
80 97 commits · 2 signature changes · 3 body rewrites
81 0 MAJOR · 5 MINOR · 11 PATCH
82 Est. 25% of original implementation remains
83 Assessment: evolving — contract is extending actively
84
85 ⚠️ Warnings
86 • No return type annotation — return type inferred by callers
87 • 2 call sites discard the return value — check for misuse
88
89 JSON output (``--json``)::
90
91 {
92 "address": "billing.py::Invoice.compute_total",
93 "name": "compute_total",
94 "kind": "method",
95 "signature": "def compute_total(items, currency='USD') -> float",
96 "parameters": [
97 { "name": "items", "annotation": null, "has_default": false,
98 "default_str": null },
99 { "name": "currency", "annotation": null, "has_default": true,
100 "default_str": "'USD'" }
101 ],
102 "return_annotation": "float",
103 "call_sites": 12,
104 "caller_files": 4,
105 "return_dispositions": {
106 "stored": 9, "discarded": 2, "returned": 1, "asserted": 0,
107 "compared": 0, "argument": 0, "unknown": 0
108 },
109 "arg_observations": [
110 { "param_index": 0, "categories": {"list": 8, "name": 3, "call": 1},
111 "none_count": 0, "always_provided": true },
112 { "param_index": 1, "categories": {"str": 4}, "none_count": 0,
113 "always_provided": false, "omit_count": 8 }
114 ],
115 "test_assertions": [
116 "assert result == expected_total",
117 "assert result > 0",
118 "assert isinstance(result, (int, float))"
119 ],
120 "commit_signals": [
121 { "bump": "minor", "message": "feat: add optional currency parameter",
122 "date": "2026-02-03" }
123 ],
124 "history": {
125 "commits_analysed": 97, "truncated": false,
126 "major_bumps": 0, "minor_bumps": 5, "patch_bumps": 11,
127 "sig_changes": 2, "impl_changes": 3, "est_survival_pct": 25
128 },
129 "preconditions": [ "items is always a list or list-comprehension" ],
130 "postconditions": [ "return value is stored at 75%% of call sites" ],
131 "warnings": [
132 "no return type annotation",
133 "2 call sites discard the return value"
134 ],
135 "stability": "evolving"
136 }
137
138 Security note
139 -------------
140 Symbol addresses are validated before any store access. Commit messages are
141 sanitised (control characters stripped) before display. All file access goes
142 through the content-addressed object store — no user-supplied paths are
143 opened directly.
144 """
145
146 import argparse
147 import ast
148 import datetime
149 import json
150 import logging
151 import pathlib
152 import re
153 import sys
154 from collections import Counter
155 from dataclasses import dataclass
156 from typing import Literal, TypedDict
157
158 from muse.core.errors import ExitCode
159 from muse.core.repo import require_repo
160 from muse.core.envelope import EnvelopeJson, make_envelope
161 from muse.core.timing import start_timer
162 from muse.core.refs import read_current_branch
163 from muse.core.commits import resolve_commit_ref
164 from muse.core.snapshots import get_commit_snapshot_manifest
165 from muse.domain import DomainOp
166 from muse.plugins.code._callgraph import (
167 ReverseGraph,
168 call_name,
169 find_func_node,
170 transitive_callers,
171 )
172 from muse.plugins.code._query import flat_symbol_ops, symbols_for_snapshot, walk_commits_bfs
173 from muse.plugins.code.ast_parser import SymbolRecord, parse_symbols
174 from muse.core.validation import clamp_int, MAX_AST_BYTES, sanitize_display
175
176 type _BlobMap = dict[str, bytes]
177 type _SymbolIndex = dict[str, SymbolRecord]
178 type _CounterMap = dict[str, int]
179
180 logger = logging.getLogger(__name__)
181
182 # ── Constants ──────────────────────────────────────────────────────────────────
183
184 _DEFAULT_MAX_COMMITS = 2_000
185 _DEFAULT_MAX_CALL_SITES = 200 # cap to keep analysis bounded
186 _PY_SUFFIXES: frozenset[str] = frozenset({".py", ".pyi"})
187
188 _TEST_PATTERNS: tuple[re.Pattern[str], ...] = (
189 re.compile(r"(^|/)test_"),
190 re.compile(r"_test\.py$"),
191 re.compile(r"(^|/)tests/"),
192 re.compile(r"(^|/)spec/"),
193 re.compile(r"(^|/)conftest\.py$"),
194 )
195
196 # Keywords in commit messages that signal contract-relevant events.
197 _CONTRACT_SIGNAL_WORDS: frozenset[str] = frozenset(
198 {
199 "breaking", "break", "invariant", "contract", "guarantee", "guarantee:",
200 "deprecated", "deprecate", "must", "require", "require:", "assert",
201 "precondition", "postcondition", "never", "always", "invariants",
202 }
203 )
204
205 _CTRL_RE = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f\x1b]")
206
207 # Op summary keywords for change classification (same as age / narrative).
208 _IMPL_KW: frozenset[str] = frozenset({"implementation", "modified", "body", "reformatted"})
209 _SIG_KW: frozenset[str] = frozenset({"signature"})
210
211 # ── TypedDicts ─────────────────────────────────────────────────────────────────
212
213 class _ParamInfo(TypedDict):
214 name: str
215 annotation: str | None
216 has_default: bool
217 default_str: str | None
218
219 class _ArgObs(TypedDict):
220 """Observed argument patterns at call sites for one positional slot."""
221
222 param_index: int
223 categories: _CounterMap # ast category → count
224 none_count: int
225 always_provided: bool
226 omit_count: int
227
228 class _CommitSignal(TypedDict):
229 bump: str
230 message: str
231 date: str
232
233 class _HistorySummary(TypedDict):
234 commits_analysed: int
235 truncated: bool
236 major_bumps: int
237 minor_bumps: int
238 patch_bumps: int
239 sig_changes: int
240 impl_changes: int
241 est_survival_pct: int
242
243 class _ContractJson(EnvelopeJson):
244 address: str
245 name: str
246 kind: str
247 signature: str
248 parameters: list[_ParamInfo]
249 return_annotation: str | None
250 call_sites: int
251 caller_files: int
252 return_dispositions: _CounterMap
253 arg_observations: list[_ArgObs]
254 test_assertions: list[str]
255 commit_signals: list[_CommitSignal]
256 history: _HistorySummary
257 preconditions: list[str]
258 postconditions: list[str]
259 stability: str
260
261 # ── Internal accumulators ──────────────────────────────────────────────────────
262
263 @dataclass
264 class _CallSiteRecord:
265 """Data collected from a single call to the target symbol."""
266
267 caller_addr: str
268 is_test: bool
269 positional_count: int
270 keyword_names: list[str]
271 disposition: str # stored|returned|asserted|discarded|compared|argument|unknown
272 arg_categories: list[str] # one entry per positional arg
273
274 # ── Helpers ────────────────────────────────────────────────────────────────────
275
276 def _is_test_file(fp: str) -> bool:
277 return any(p.search(fp) for p in _TEST_PATTERNS)
278
279 def _sanitise(s: str, max_len: int = 80) -> str:
280 clean = _CTRL_RE.sub("", s).strip()
281 return f"{clean[:max_len - 1]}…" if len(clean) > max_len else clean
282
283 def _arg_category(node: ast.expr) -> str:
284 """Classify an AST argument expression into a coarse type category."""
285 if isinstance(node, ast.Constant):
286 if node.value is None:
287 return "None"
288 return type(node.value).__name__ # "str", "int", "float", "bool"
289 if isinstance(node, ast.List):
290 return "list"
291 if isinstance(node, ast.Dict):
292 return "dict"
293 if isinstance(node, ast.Tuple):
294 return "tuple"
295 if isinstance(node, ast.Set):
296 return "set"
297 if isinstance(node, (ast.ListComp, ast.GeneratorExp, ast.DictComp, ast.SetComp)):
298 return "comprehension"
299 if isinstance(node, ast.Call):
300 return "call"
301 if isinstance(node, ast.Name):
302 return f"var"
303 return "other"
304
305 def _extract_direct_call(node: ast.expr | None, target_name: str) -> ast.Call | None:
306 """If *node* is a direct ast.Call to *target_name*, return it; else None."""
307 if isinstance(node, ast.Call):
308 n = call_name(node.func)
309 if n == target_name:
310 return node
311 return None
312
313 def _classify_disposition(
314 stmts: list[ast.stmt],
315 target_name: str,
316 ) -> list[tuple[str, list[ast.expr], list[ast.keyword]]]:
317 """Walk *stmts* and classify how each direct call to *target_name* is used.
318
319 Returns a list of ``(disposition, positional_args, keywords)`` tuples.
320 """
321 results: list[tuple[str, list[ast.expr], list[ast.keyword]]] = []
322
323 for stmt in stmts:
324 if isinstance(stmt, ast.Expr):
325 c = _extract_direct_call(stmt.value, target_name)
326 if c:
327 results.append(("discarded", list(c.args), list(c.keywords)))
328
329 elif isinstance(stmt, (ast.Assign, ast.AnnAssign)):
330 val = stmt.value if isinstance(stmt, ast.Assign) else stmt.value
331 if val is not None:
332 c = _extract_direct_call(val, target_name)
333 if c:
334 results.append(("stored", list(c.args), list(c.keywords)))
335
336 elif isinstance(stmt, ast.Return) and stmt.value is not None:
337 c = _extract_direct_call(stmt.value, target_name)
338 if c:
339 results.append(("returned", list(c.args), list(c.keywords)))
340
341 elif isinstance(stmt, ast.Assert):
342 c = _extract_direct_call(stmt.test, target_name)
343 if c:
344 results.append(("asserted", list(c.args), list(c.keywords)))
345
346 elif isinstance(stmt, ast.If):
347 c = _extract_direct_call(stmt.test, target_name)
348 if c:
349 results.append(("compared", list(c.args), list(c.keywords)))
350 results.extend(_classify_disposition(stmt.body, target_name))
351 results.extend(_classify_disposition(stmt.orelse, target_name))
352
353 elif isinstance(stmt, (ast.For, ast.While)):
354 results.extend(_classify_disposition(stmt.body, target_name))
355
356 elif isinstance(stmt, ast.With):
357 results.extend(_classify_disposition(stmt.body, target_name))
358
359 elif isinstance(stmt, ast.Try):
360 results.extend(_classify_disposition(stmt.body, target_name))
361 for h in stmt.handlers:
362 results.extend(_classify_disposition(h.body, target_name))
363 results.extend(_classify_disposition(stmt.finalbody, target_name))
364
365 return results
366
367 def _extract_assertions_from_test(
368 func_node: ast.FunctionDef | ast.AsyncFunctionDef,
369 target_bare_name: str,
370 ) -> list[str]:
371 """Return ``assert`` expressions from a test function that calls *target*.
372
373 Only collects assertions from test functions that contain at least one call
374 to *target_bare_name*. Caps at 8 assertions to keep output focused.
375 """
376 # Check if this function ever calls target.
377 has_call = any(
378 isinstance(node, ast.Call) and call_name(node.func) == target_bare_name
379 for node in ast.walk(func_node)
380 )
381 if not has_call:
382 return []
383
384 assertions: list[str] = []
385 for node in ast.walk(func_node):
386 if isinstance(node, ast.Assert):
387 try:
388 assertions.append(ast.unparse(node))
389 except Exception:
390 pass
391 if len(assertions) >= 8:
392 break
393 return assertions
394
395 # ── Signature extraction ───────────────────────────────────────────────────────
396
397 def _extract_signature(
398 raw: bytes,
399 address: str,
400 ) -> tuple[str, list[_ParamInfo], str | None]:
401 """Extract signature data for *address* from *raw* Python source.
402
403 Returns:
404 ``(signature_str, params, return_annotation)``
405
406 ``signature_str`` is a compact human-readable signature line.
407 ``params`` is a list of parameter info dicts.
408 ``return_annotation`` is the return type as a string, or ``None``.
409 """
410 if "::" not in address:
411 return ("", [], None)
412 sym_name = address.split("::", 1)[1]
413 try:
414 if len(raw) > MAX_AST_BYTES:
415 return ("", [], None)
416 tree = ast.parse(raw)
417 except SyntaxError:
418 return ("", [], None)
419
420 func_node = find_func_node(tree.body, sym_name.split("."))
421 if func_node is None:
422 return ("", [], None)
423
424 args = func_node.args
425 all_args = list(args.args)
426 # Include *args, **kwargs for completeness if present.
427 num_defaults = len(args.defaults)
428 num_params = len(all_args)
429 # Defaults align to the last N params.
430 default_offset = num_params - num_defaults
431
432 params: list[_ParamInfo] = []
433 for i, arg in enumerate(all_args):
434 ann = ast.unparse(arg.annotation) if arg.annotation else None
435 has_default = i >= default_offset
436 default_str: str | None = None
437 if has_default:
438 d = args.defaults[i - default_offset]
439 try:
440 default_str = ast.unparse(d)
441 except Exception:
442 default_str = "..."
443 # Skip "self" and "cls" from the public contract.
444 if arg.arg in ("self", "cls"):
445 continue
446 params.append(
447 _ParamInfo(
448 name=arg.arg,
449 annotation=ann,
450 has_default=has_default,
451 default_str=default_str,
452 )
453 )
454
455 ret_ann: str | None = None
456 if func_node.returns:
457 try:
458 ret_ann = ast.unparse(func_node.returns)
459 except Exception:
460 pass
461
462 # Build compact signature string.
463 parts: list[str] = []
464 for i, arg in enumerate(all_args):
465 if arg.arg in ("self", "cls"):
466 continue
467 ann_str = f": {ast.unparse(arg.annotation)}" if arg.annotation else ""
468 has_default = (all_args.index(arg)) >= default_offset
469 def_str = ""
470 if has_default:
471 d = args.defaults[all_args.index(arg) - default_offset]
472 try:
473 def_str = f"={ast.unparse(d)}"
474 except Exception:
475 def_str = "=..."
476 parts.append(f"{arg.arg}{ann_str}{def_str}")
477
478 bare_name = sym_name.split(".")[-1]
479 ret_str = f" -> {ret_ann}" if ret_ann else ""
480 signature_str = f"def {bare_name}({', '.join(parts)}){ret_str}"
481
482 return signature_str, params, ret_ann
483
484 # ── Call-site analysis ─────────────────────────────────────────────────────────
485
486 def _analyze_call_sites(
487 blobs: _BlobMap,
488 direct_callers: list[str],
489 target_bare_name: str,
490 ) -> list[_CallSiteRecord]:
491 """Analyse how each direct caller invokes the target symbol.
492
493 Args:
494 blobs: Pre-loaded Python blobs.
495 direct_callers: List of caller symbol addresses (direct callers only).
496 target_bare_name: Bare name of the target symbol.
497
498 Returns:
499 One ``_CallSiteRecord`` per call to the target found.
500 """
501 records: list[_CallSiteRecord] = []
502
503 for caller_addr in direct_callers[:_DEFAULT_MAX_CALL_SITES]:
504 if "::" not in caller_addr:
505 continue
506 file_path = caller_addr.split("::")[0]
507 sym_name = caller_addr.split("::", 1)[1]
508 raw = blobs.get(file_path)
509 if raw is None:
510 continue
511
512 try:
513 if len(raw) > MAX_AST_BYTES:
514 continue
515 tree = ast.parse(raw)
516 except SyntaxError:
517 continue
518
519 func_node = find_func_node(tree.body, sym_name.split("."))
520 if func_node is None:
521 continue
522
523 is_test = _is_test_file(file_path)
524 call_items = _classify_disposition(list(func_node.body), target_bare_name)
525
526 for disposition, pos_args, kw_args in call_items:
527 arg_cats = [_arg_category(a) for a in pos_args]
528 kw_names = [k.arg for k in kw_args if k.arg is not None]
529 records.append(
530 _CallSiteRecord(
531 caller_addr=caller_addr,
532 is_test=is_test,
533 positional_count=len(pos_args),
534 keyword_names=kw_names,
535 disposition=disposition,
536 arg_categories=arg_cats,
537 )
538 )
539
540 return records
541
542 def _extract_all_test_assertions(
543 blobs: _BlobMap,
544 direct_callers: list[str],
545 target_bare_name: str,
546 ) -> list[str]:
547 """Collect assert statements from test callers of the target.
548
549 Deduplicates across all test functions and caps at 10 total.
550 """
551 seen: set[str] = set()
552 assertions: list[str] = []
553
554 for caller_addr in direct_callers:
555 if "::" not in caller_addr:
556 continue
557 file_path = caller_addr.split("::")[0]
558 if not _is_test_file(file_path):
559 continue
560 sym_name = caller_addr.split("::", 1)[1]
561 raw = blobs.get(file_path)
562 if raw is None:
563 continue
564
565 try:
566 if len(raw) > MAX_AST_BYTES:
567 continue
568 tree = ast.parse(raw)
569 except SyntaxError:
570 continue
571
572 func_node = find_func_node(tree.body, sym_name.split("."))
573 if func_node is None:
574 continue
575
576 for assertion in _extract_assertions_from_test(func_node, target_bare_name):
577 if assertion not in seen:
578 seen.add(assertion)
579 assertions.append(assertion)
580 if len(assertions) >= 10:
581 return assertions
582
583 return assertions
584
585 # ── History analysis ───────────────────────────────────────────────────────────
586
587 def _collect_history(
588 root: pathlib.Path,
589 head_commit_id: str,
590 address: str,
591 max_commits: int,
592 ) -> tuple[_HistorySummary, list[_CommitSignal]]:
593 """Walk the commit DAG and build a history summary for *address*.
594
595 Returns:
596 ``(history_summary, commit_signals)``
597 """
598 commits, truncated = walk_commits_bfs(root, head_commit_id, max_commits)
599
600 major = minor = patch = sig = impl = 0
601 signals: list[_CommitSignal] = []
602
603 for commit in commits:
604 if commit.structured_delta is None:
605 continue
606 ops: list[DomainOp] = commit.structured_delta["ops"]
607
608 touched = False
609 for op in flat_symbol_ops(ops):
610 if op["address"] != address:
611 continue
612 touched = True
613 kind = op.get("op", "")
614 if kind == "replace":
615 new_sum = str(op.get("new_summary") or "").lower()
616 old_sum = str(op.get("old_summary") or "").lower()
617 if any(kw in new_sum for kw in _SIG_KW):
618 sig += 1
619 elif any(kw in new_sum for kw in _IMPL_KW) or any(
620 kw in old_sum for kw in _IMPL_KW
621 ):
622 impl += 1
623 else:
624 impl += 1 # conservative
625
626 if not touched:
627 continue
628
629 bump = (commit.sem_ver_bump if commit.sem_ver_bump else "none").lower()
630 if bump == "major":
631 major += 1
632 elif bump == "minor":
633 minor += 1
634 elif bump == "patch":
635 patch += 1
636
637 # Extract contract signals from commit message.
638 msg = _sanitise(commit.message, max_len=100)
639 msg_lower = msg.lower()
640 if any(w in msg_lower for w in _CONTRACT_SIGNAL_WORDS):
641 signals.append(
642 _CommitSignal(
643 bump=bump,
644 message=msg,
645 date=commit.committed_at.strftime("%Y-%m-%d"),
646 )
647 )
648
649 est_survival = round(100 / (impl + 1))
650
651 return (
652 _HistorySummary(
653 commits_analysed=len(commits),
654 truncated=truncated,
655 major_bumps=major,
656 minor_bumps=minor,
657 patch_bumps=patch,
658 sig_changes=sig,
659 impl_changes=impl,
660 est_survival_pct=est_survival,
661 ),
662 signals[:20], # cap signals to keep output readable
663 )
664
665 # ── Contract inference ─────────────────────────────────────────────────────────
666
667 def _infer_contract(
668 params: list[_ParamInfo],
669 call_records: list[_CallSiteRecord],
670 history: _HistorySummary,
671 ) -> tuple[list[str], list[str], list[str], str]:
672 """Infer preconditions, postconditions, warnings, and stability assessment.
673
674 Returns:
675 ``(preconditions, postconditions, warnings, stability)``
676 """
677 pre: list[str] = []
678 post: list[str] = []
679 warn: list[str] = []
680
681 n = len(call_records)
682
683 # ── Return value patterns ──────────────────────────────────────────────────
684 disp_counts: Counter[str] = Counter(r.disposition for r in call_records)
685 discarded = disp_counts.get("discarded", 0)
686 stored = disp_counts.get("stored", 0)
687 returned = disp_counts.get("returned", 0)
688 asserted = disp_counts.get("asserted", 0)
689 compared = disp_counts.get("compared", 0)
690
691 if n > 0:
692 use_count = n - discarded
693 if use_count > 0:
694 post.append(
695 f"Return value is used at {use_count}/{n} call sites"
696 f" ({round(use_count / n * 100)}%)"
697 )
698 if stored > 0:
699 post.append(
700 f"Return value is stored at {stored}/{n} call sites"
701 f" — callers expect a meaningful result"
702 )
703 if discarded > 0:
704 warn.append(
705 f"{discarded} call site{'s' if discarded > 1 else ''} discard"
706 f" the return value — check for misuse or side-effect assumption"
707 )
708
709 # ── Argument patterns ──────────────────────────────────────────────────────
710 # For each positional param slot, gather observed arg categories.
711 for slot, param in enumerate(params):
712 slot_cats: Counter[str] = Counter()
713 omit_count = 0
714 for r in call_records:
715 if slot < len(r.arg_categories):
716 slot_cats[r.arg_categories[slot]] += 1
717 elif param["has_default"]:
718 omit_count += 1
719
720 if param["has_default"] and n > 0:
721 omit_rate = omit_count / n
722 if omit_rate > 0.5:
723 pre.append(
724 f"``{param['name']}`` defaults relied upon at"
725 f" {omit_count}/{n} call sites — effectively optional"
726 )
727 elif omit_rate == 0.0:
728 pre.append(
729 f"``{param['name']}`` always provided explicitly — callers"
730 " never rely on the default"
731 )
732
733 if slot_cats:
734 top_cat, top_n = slot_cats.most_common(1)[0]
735 if top_n == len(call_records) and len(call_records) > 1:
736 pre.append(
737 f"``{param['name']}`` is always a {top_cat}"
738 f" ({top_n} call sites agree)"
739 )
740 elif "None" in slot_cats:
741 none_count = slot_cats["None"]
742 pre.append(
743 f"``{param['name']}`` is sometimes None"
744 f" ({none_count}/{n} call sites)"
745 )
746
747 # ── Annotation gaps ────────────────────────────────────────────────────────
748 unannotated = [p["name"] for p in params if not p["annotation"]]
749 if unannotated:
750 warn.append(
751 f"No type annotations on: {', '.join(unannotated)}"
752 " — contract is implicit"
753 )
754
755 # ── Stability assessment ───────────────────────────────────────────────────
756 if history["major_bumps"] > 0:
757 stability = "volatile"
758 elif history["sig_changes"] > 2 or history["minor_bumps"] > 3:
759 stability = "evolving"
760 elif history["impl_changes"] == 0 and history["sig_changes"] == 0:
761 stability = "dormant"
762 elif history["impl_changes"] <= 1 and history["sig_changes"] == 0:
763 stability = "stable"
764 else:
765 stability = "evolving"
766
767 return pre, post, warn, stability
768
769 # ── Formatters ─────────────────────────────────────────────────────────────────
770
771 def _pct_bar(n: int, total: int, width: int = 20) -> str:
772 if total <= 0:
773 return ""
774 filled = round(n / total * width)
775 return f"{'█' * filled}{'░' * (width - filled)}"
776
777 def _print_contract(
778 address: str,
779 name: str,
780 kind: str,
781 signature_str: str,
782 params: list[_ParamInfo],
783 ret_ann: str | None,
784 call_records: list[_CallSiteRecord],
785 test_assertions: list[str],
786 commit_signals: list[_CommitSignal],
787 history: _HistorySummary,
788 pre: list[str],
789 post: list[str],
790 warn: list[str],
791 stability: str,
792 ) -> None:
793 """Print the human-readable contract report."""
794 caller_files = len({r.caller_addr.split("::")[0] for r in call_records})
795 n = len(call_records)
796
797 print(f"\n Contract: {sanitize_display(address)}\n")
798
799 # ── Signature ──────────────────────────────────────────────────────────────
800 print(" Signature")
801 print(f" {signature_str}\n")
802
803 # ── Parameters ────────────────────────────────────────────────────────────
804 if params:
805 print(" Parameters")
806 for slot, param in enumerate(params):
807 ann = f": {param['annotation']}" if param["annotation"] else ""
808 default = f" = {param['default_str']}" if param["has_default"] else ""
809 print(f" {sanitize_display(param['name'])}{ann}{default}")
810
811 # Arg usage patterns.
812 slot_cats: Counter[str] = Counter()
813 omit_count = 0
814 for r in call_records:
815 if slot < len(r.arg_categories):
816 slot_cats[r.arg_categories[slot]] += 1
817 elif param["has_default"]:
818 omit_count += 1
819
820 lines: list[str] = []
821 if omit_count > 0 and n > 0:
822 lines.append(f"omitted at {omit_count}/{n} sites (default relied on)")
823 if slot_cats:
824 cats = ", ".join(
825 f"{cat} ({cnt}×)" for cat, cnt in slot_cats.most_common(3)
826 )
827 lines.append(f"observed: {cats}")
828 for line in lines:
829 print(f" {line}")
830 print()
831
832 # ── Return value ──────────────────────────────────────────────────────────
833 if n > 0:
834 print(" Return value")
835 disp: Counter[str] = Counter(r.disposition for r in call_records)
836 for label, count in disp.most_common():
837 if count == 0:
838 continue
839 bar = _pct_bar(count, n, width=16)
840 warn_flag = " ⚠️" if label == "discarded" and count > 1 else ""
841 print(f" → {label:<12} {count:>3}/{n} [{bar}]{warn_flag}")
842 print()
843
844 # ── Preconditions ─────────────────────────────────────────────────────────
845 if pre:
846 print(" Inferred preconditions")
847 for p in pre:
848 print(f" • {p}")
849 print()
850
851 # ── Postconditions ────────────────────────────────────────────────────────
852 if post:
853 print(" Inferred postconditions")
854 for p in post:
855 print(f" • {p}")
856 print()
857
858 # ── Test assertions ───────────────────────────────────────────────────────
859 test_sites = sum(1 for r in call_records if r.is_test)
860 if test_assertions:
861 print(f" Test assertions ({test_sites} test call site{'s' if test_sites != 1 else ''})")
862 for a in test_assertions:
863 print(f" {a}")
864 print()
865
866 # ── Commit signals ────────────────────────────────────────────────────────
867 if commit_signals:
868 print(" Commit signals")
869 for sig in commit_signals[:5]:
870 print(f" {sig['bump'].upper():<8} \"{sanitize_display(sig['message'][:60])}\" ({sig['date']})")
871 print()
872
873 # ── Stability ─────────────────────────────────────────────────────────────
874 print(" Stability")
875 h = history
876 print(
877 f" {h['commits_analysed']} commits"
878 f" · {h['sig_changes']} signature change{'s' if h['sig_changes'] != 1 else ''}"
879 f" · {h['impl_changes']} body rewrite{'s' if h['impl_changes'] != 1 else ''}"
880 )
881 print(
882 f" {h['major_bumps']} MAJOR"
883 f" · {h['minor_bumps']} MINOR"
884 f" · {h['patch_bumps']} PATCH"
885 )
886 print(f" Est. {h['est_survival_pct']}% of original implementation remains")
887 stability_desc = {
888 "stable": "stable — contract has been consistent",
889 "evolving": "evolving — contract is extending actively",
890 "volatile": "volatile — MAJOR contract breaks in history",
891 "dormant": "dormant — rarely changed",
892 }.get(stability, stability)
893 print(f" Assessment: {stability_desc}\n")
894
895 # ── Warnings ──────────────────────────────────────────────────────────────
896 if warn:
897 print(" ⚠️ Warnings")
898 for w in warn:
899 print(f" • {w}")
900 print()
901
902 # ── Entry point ────────────────────────────────────────────────────────────────
903
904 def run(args: argparse.Namespace) -> None:
905 """Infer the behavioural contract of a symbol from its call sites and history.
906
907 Analyses all call sites, return value dispositions, observed argument
908 categories, test assertions, and commit signals to produce a rich contract
909 profile: parameters, stability, preconditions, postconditions, and history
910 summary.
911
912 Agent quickstart
913 ----------------
914 ::
915
916 muse code contract "billing.py::compute_tax" --json
917 muse code contract "billing.py::compute_tax" --max-commits 200 --json
918
919 JSON fields
920 -----------
921 address Symbol address analysed.
922 name Symbol name.
923 kind Symbol kind (``"function"``, ``"method"``, etc.).
924 signature Full signature string.
925 parameters List of parameter objects with inferred type hints.
926 return_annotation Return type annotation or ``null``.
927 call_sites Total number of call sites found.
928 caller_files Number of distinct caller files.
929 return_dispositions Map of disposition → count (``"stored"``, ``"returned"``, etc.).
930 arg_observations List of observed argument category distributions.
931 test_assertions List of assertion patterns found in test call sites.
932 commit_signals List of signals from commit history.
933 history Summary: ``total_commits``, ``avg_churn``, ``first_seen``, ``last_modified``.
934 preconditions Inferred precondition strings.
935 postconditions Inferred postcondition strings.
936 stability Stability label: ``"stable"``, ``"volatile"``, ``"new"``.
937
938 Exit codes
939 ----------
940 0 Analysis complete.
941 1 Invalid address format or symbol not found.
942 2 Not inside a Muse repository.
943 """
944 elapsed = start_timer()
945 root = require_repo()
946
947 # ── Argument validation ────────────────────────────────────────────────────
948 address: str = args.address.strip()
949 if "::" not in address:
950 print(
951 "❌ ADDRESS must be in ``file::Symbol`` format.",
952 file=sys.stderr,
953 )
954 raise SystemExit(ExitCode.USER_ERROR)
955
956 max_commits: int = clamp_int(args.max_commits, 1, 100000, 'max_commits')
957 if max_commits < 1:
958 print("❌ --max-commits must be >= 1.", file=sys.stderr)
959 raise SystemExit(ExitCode.USER_ERROR)
960
961 # ── Resolve HEAD ───────────────────────────────────────────────────────────
962 branch = read_current_branch(root)
963
964 head = resolve_commit_ref(root, branch, None)
965 if head is None:
966 print("❌ HEAD commit not found — is this an empty repository?", file=sys.stderr)
967 raise SystemExit(ExitCode.USER_ERROR)
968
969 manifest = get_commit_snapshot_manifest(root, head.commit_id) or {}
970
971 # ── Load all Python blobs in one pass ─────────────────────────────────────
972 from muse.core.object_store import read_object
973
974 blobs: _BlobMap = {}
975 for fp, obj_id in manifest.items():
976 if pathlib.PurePosixPath(fp).suffix.lower() in _PY_SUFFIXES:
977 raw = read_object(root, obj_id)
978 if raw is not None:
979 blobs[fp] = raw
980
981 # ── Verify symbol exists at HEAD ───────────────────────────────────────────
982 all_trees = symbols_for_snapshot(root, manifest)
983 addr_to_rec: _SymbolIndex = {}
984 for sym_tree in all_trees.values():
985 addr_to_rec.update(sym_tree)
986
987 if address not in addr_to_rec:
988 print(f"❌ {sanitize_display(address)!r} not found in HEAD snapshot.", file=sys.stderr)
989 print(" Use ``muse code symbols`` to list available addresses.", file=sys.stderr)
990 raise SystemExit(ExitCode.USER_ERROR)
991
992 rec = addr_to_rec[address]
993 name = rec["name"]
994 kind = rec["kind"]
995 file_path = address.split("::")[0]
996
997 # ── Extract current signature ──────────────────────────────────────────────
998 raw_file = blobs.get(file_path)
999 params: list[_ParamInfo]
1000 if raw_file is None:
1001 signature_str, params, ret_ann = "", [], None
1002 else:
1003 signature_str, params, ret_ann = _extract_signature(raw_file, address)
1004
1005 # ── Build forward + reverse call graph from production blobs ──────────────
1006 from muse.plugins.code._callgraph import ForwardGraph
1007
1008 forward: ForwardGraph = {}
1009 for fp, raw in blobs.items():
1010 if _is_test_file(fp):
1011 continue
1012 try:
1013 if len(raw) > MAX_AST_BYTES:
1014 continue
1015 tree_ast = ast.parse(raw)
1016 except SyntaxError:
1017 continue
1018 sym_tree = parse_symbols(raw, fp)
1019 for addr2, rec2 in sym_tree.items():
1020 if rec2["kind"] not in {"function", "async_function", "method", "async_method"}:
1021 continue
1022 fn = find_func_node(tree_ast.body, rec2["qualified_name"].split("."))
1023 if fn is None:
1024 continue
1025 callees: set[str] = set()
1026 for node in ast.walk(fn):
1027 if isinstance(node, ast.Call):
1028 n2 = call_name(node.func)
1029 if n2:
1030 callees.add(n2)
1031 forward[addr2] = frozenset(callees)
1032
1033 # Invert → reverse graph.
1034 reverse: ReverseGraph = {}
1035 for caller_addr, callee_names in forward.items():
1036 for cn in callee_names:
1037 reverse.setdefault(cn, []).append(caller_addr)
1038 for cn in reverse:
1039 reverse[cn].sort()
1040
1041 # Find direct callers only (depth=1).
1042 depth_map = transitive_callers(name, reverse, max_depth=1)
1043 direct_callers: list[str] = list(depth_map.get(1, []))
1044
1045 # Also include test-file callers for test assertion extraction.
1046 # Build a separate test reverse graph including test files.
1047 test_forward: ForwardGraph = {}
1048 for fp, raw in blobs.items():
1049 if not _is_test_file(fp):
1050 continue
1051 try:
1052 if len(raw) > MAX_AST_BYTES:
1053 continue
1054 tree_ast = ast.parse(raw)
1055 except SyntaxError:
1056 continue
1057 sym_tree = parse_symbols(raw, fp)
1058 for addr2, rec2 in sym_tree.items():
1059 if rec2["kind"] not in {"function", "async_function", "method", "async_method"}:
1060 continue
1061 fn = find_func_node(tree_ast.body, rec2["qualified_name"].split("."))
1062 if fn is None:
1063 continue
1064 callees = set()
1065 for node in ast.walk(fn):
1066 if isinstance(node, ast.Call):
1067 n2 = call_name(node.func)
1068 if n2:
1069 callees.add(n2)
1070 test_forward[addr2] = frozenset(callees)
1071
1072 test_reverse: ReverseGraph = {}
1073 for addr2, callee_names in test_forward.items():
1074 for cn in callee_names:
1075 test_reverse.setdefault(cn, []).append(addr2)
1076
1077 test_callers = list(transitive_callers(name, test_reverse, max_depth=1).get(1, []))
1078 all_direct = direct_callers + test_callers
1079
1080 # ── Analyse call sites ─────────────────────────────────────────────────────
1081 call_records = _analyze_call_sites(blobs, all_direct, name)
1082
1083 # ── Extract test assertions ────────────────────────────────────────────────
1084 test_assertions = _extract_all_test_assertions(blobs, test_callers, name)
1085
1086 # ── Commit history ────────────────────────────────────────────────────────
1087 history, commit_signals = _collect_history(root, head.commit_id, address, max_commits)
1088
1089 # ── Infer contract ────────────────────────────────────────────────────────
1090 pre, post, warn, stability = _infer_contract(params, call_records, history)
1091
1092 # ── Output ────────────────────────────────────────────────────────────────
1093 if args.json_out:
1094 disp_counts: _CounterMap = {
1095 "stored": 0, "returned": 0, "asserted": 0,
1096 "discarded": 0, "compared": 0, "argument": 0, "unknown": 0,
1097 }
1098 for r in call_records:
1099 disp_counts[r.disposition] = disp_counts.get(r.disposition, 0) + 1
1100
1101 # Arg observations per param slot.
1102 arg_obs: list[_ArgObs] = []
1103 for slot, param in enumerate(params):
1104 cats: Counter[str] = Counter()
1105 omit = 0
1106 for r in call_records:
1107 if slot < len(r.arg_categories):
1108 cats[r.arg_categories[slot]] += 1
1109 elif param["has_default"]:
1110 omit += 1
1111 arg_obs.append(
1112 _ArgObs(
1113 param_index=slot,
1114 categories=dict(cats),
1115 none_count=cats.get("None", 0),
1116 always_provided=omit == 0,
1117 omit_count=omit,
1118 )
1119 )
1120
1121 caller_files = len({r.caller_addr.split("::")[0] for r in call_records})
1122 print(json.dumps(_ContractJson(
1123 **make_envelope(elapsed, warnings=warn),
1124 address=address,
1125 name=name,
1126 kind=kind,
1127 signature=signature_str,
1128 parameters=params,
1129 return_annotation=ret_ann,
1130 call_sites=len(call_records),
1131 caller_files=caller_files,
1132 return_dispositions=disp_counts,
1133 arg_observations=arg_obs,
1134 test_assertions=test_assertions,
1135 commit_signals=commit_signals,
1136 history=history,
1137 preconditions=pre,
1138 postconditions=post,
1139 stability=stability,
1140 )))
1141 return
1142
1143 _print_contract(
1144 address, name, kind, signature_str, params, ret_ann,
1145 call_records, test_assertions, commit_signals,
1146 history, pre, post, warn, stability,
1147 )
1148
1149 # ── CLI registration ───────────────────────────────────────────────────────────
1150
1151 def register(
1152 sub: argparse._SubParsersAction[argparse.ArgumentParser],
1153 ) -> None:
1154 """Register ``contract`` under the ``code`` subcommand group."""
1155 p = sub.add_parser(
1156 "contract",
1157 help=(
1158 "Infer the implicit behavioral contract of a symbol from its"
1159 " call sites, test assertions, and commit history."
1160 ),
1161 description=__doc__,
1162 formatter_class=argparse.RawDescriptionHelpFormatter,
1163 )
1164 p.add_argument(
1165 "address",
1166 metavar="ADDRESS",
1167 help=(
1168 "Full symbol address"
1169 " (e.g. billing.py::Invoice.compute_total)."
1170 ),
1171 )
1172 p.add_argument(
1173 "--max-commits",
1174 type=int,
1175 default=_DEFAULT_MAX_COMMITS,
1176 metavar="N",
1177 help=f"Maximum commits to walk for history analysis (default: {_DEFAULT_MAX_COMMITS}).",
1178 )
1179 p.add_argument(
1180 "--json", "-j",
1181 action="store_true",
1182 dest="json_out",
1183 help="Emit JSON instead of human-readable text.",
1184 )
1185 p.set_defaults(func=run)
File History 1 commit
sha256:057be43e401106b2b4d877e8028ae67d16a966562d7429707b71a5eaacd5027b docs(mwp-master): tick all ACs green; mark MWP-7 complete w… Sonnet 4.6 20 days ago