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