codmap 0.0.4__tar.gz → 0.0.5__tar.gz

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.
Files changed (119) hide show
  1. {codmap-0.0.4 → codmap-0.0.5}/PKG-INFO +1 -1
  2. {codmap-0.0.4 → codmap-0.0.5}/codemap/apidiff.py +15 -0
  3. {codmap-0.0.4 → codmap-0.0.5}/codemap/arch.py +13 -4
  4. {codmap-0.0.4 → codmap-0.0.5}/codemap/cli.py +8 -1
  5. {codmap-0.0.4 → codmap-0.0.5}/codemap/extract/griffe_extractor.py +71 -9
  6. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/check.py +12 -2
  7. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/mcp_server.py +8 -2
  8. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/session.py +31 -2
  9. {codmap-0.0.4 → codmap-0.0.5}/codmap.egg-info/PKG-INFO +1 -1
  10. {codmap-0.0.4 → codmap-0.0.5}/codmap.egg-info/SOURCES.txt +4 -0
  11. {codmap-0.0.4 → codmap-0.0.5}/pyproject.toml +1 -1
  12. codmap-0.0.5/tests/test_r1c33_query_signature.py +189 -0
  13. codmap-0.0.5/tests/test_r1c34_signature_kinds.py +150 -0
  14. codmap-0.0.5/tests/test_r1c35_contract_location.py +101 -0
  15. codmap-0.0.5/tests/test_r1c36_target_resolution.py +107 -0
  16. {codmap-0.0.4 → codmap-0.0.5}/LICENSE +0 -0
  17. {codmap-0.0.4 → codmap-0.0.5}/README.md +0 -0
  18. {codmap-0.0.4 → codmap-0.0.5}/codemap/__init__.py +0 -0
  19. {codmap-0.0.4 → codmap-0.0.5}/codemap/diagnostics.py +0 -0
  20. {codmap-0.0.4 → codmap-0.0.5}/codemap/extract/__init__.py +0 -0
  21. {codmap-0.0.4 → codmap-0.0.5}/codemap/extract/attrflow.py +0 -0
  22. {codmap-0.0.4 → codmap-0.0.5}/codemap/extract/behavior.py +0 -0
  23. {codmap-0.0.4 → codmap-0.0.5}/codemap/extract/dataflow.py +0 -0
  24. {codmap-0.0.4 → codmap-0.0.5}/codemap/extract/dispatch.py +0 -0
  25. {codmap-0.0.4 → codmap-0.0.5}/codemap/extract/gsource.py +0 -0
  26. {codmap-0.0.4 → codmap-0.0.5}/codemap/extract/roots.py +0 -0
  27. {codmap-0.0.4 → codmap-0.0.5}/codemap/freshness.py +0 -0
  28. {codmap-0.0.4 → codmap-0.0.5}/codemap/incremental.py +0 -0
  29. {codmap-0.0.4 → codmap-0.0.5}/codemap/integrations/__init__.py +0 -0
  30. {codmap-0.0.4 → codmap-0.0.5}/codemap/integrations/base.py +0 -0
  31. {codmap-0.0.4 → codmap-0.0.5}/codemap/integrations/cocoindex.py +0 -0
  32. {codmap-0.0.4 → codmap-0.0.5}/codemap/integrations/gate.py +0 -0
  33. {codmap-0.0.4 → codmap-0.0.5}/codemap/integrations/gitnexus.py +0 -0
  34. {codmap-0.0.4 → codmap-0.0.5}/codemap/integrations/registry.py +0 -0
  35. {codmap-0.0.4 → codmap-0.0.5}/codemap/integrations/transport.py +0 -0
  36. {codmap-0.0.4 → codmap-0.0.5}/codemap/model.py +0 -0
  37. {codmap-0.0.4 → codmap-0.0.5}/codemap/provenance.py +0 -0
  38. {codmap-0.0.4 → codmap-0.0.5}/codemap/query.py +0 -0
  39. {codmap-0.0.4 → codmap-0.0.5}/codemap/scope.py +0 -0
  40. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/__init__.py +0 -0
  41. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/_scip_pb2.py +0 -0
  42. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/api_surface.py +0 -0
  43. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/apidiff.py +0 -0
  44. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/architecture.py +0 -0
  45. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/audit.py +0 -0
  46. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/ctags.py +0 -0
  47. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/impact.py +0 -0
  48. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/limits.py +0 -0
  49. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/livingdocs.py +0 -0
  50. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/mermaid.py +0 -0
  51. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/pack.py +0 -0
  52. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/rag.py +0 -0
  53. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/review.py +0 -0
  54. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/scip.py +0 -0
  55. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/semantic.py +0 -0
  56. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/server.py +0 -0
  57. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/subsystems.py +0 -0
  58. {codmap-0.0.4 → codmap-0.0.5}/codemap/serve/vault.py +0 -0
  59. {codmap-0.0.4 → codmap-0.0.5}/codemap/store.py +0 -0
  60. {codmap-0.0.4 → codmap-0.0.5}/codemap/tomlio.py +0 -0
  61. {codmap-0.0.4 → codmap-0.0.5}/codemap/watch.py +0 -0
  62. {codmap-0.0.4 → codmap-0.0.5}/codmap.egg-info/dependency_links.txt +0 -0
  63. {codmap-0.0.4 → codmap-0.0.5}/codmap.egg-info/entry_points.txt +0 -0
  64. {codmap-0.0.4 → codmap-0.0.5}/codmap.egg-info/requires.txt +0 -0
  65. {codmap-0.0.4 → codmap-0.0.5}/codmap.egg-info/top_level.txt +0 -0
  66. {codmap-0.0.4 → codmap-0.0.5}/setup.cfg +0 -0
  67. {codmap-0.0.4 → codmap-0.0.5}/tests/test_epistemic.py +0 -0
  68. {codmap-0.0.4 → codmap-0.0.5}/tests/test_gitnexus_router.py +0 -0
  69. {codmap-0.0.4 → codmap-0.0.5}/tests/test_impact_depth.py +0 -0
  70. {codmap-0.0.4 → codmap-0.0.5}/tests/test_integrations.py +0 -0
  71. {codmap-0.0.4 → codmap-0.0.5}/tests/test_issue3_serve_freshness.py +0 -0
  72. {codmap-0.0.4 → codmap-0.0.5}/tests/test_livingdocs.py +0 -0
  73. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m0_api_surface.py +0 -0
  74. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m11_argcontract.py +0 -0
  75. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m12_dataflow.py +0 -0
  76. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m15_review.py +0 -0
  77. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m16_architecture.py +0 -0
  78. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m17_mcp.py +0 -0
  79. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m18_freshness.py +0 -0
  80. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m1_5_semantics.py +0 -0
  81. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m1_query.py +0 -0
  82. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m2_views.py +0 -0
  83. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m32_watch.py +0 -0
  84. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m3_serve.py +0 -0
  85. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m4_behavior.py +0 -0
  86. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m5_deep.py +0 -0
  87. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m6_repo_scope.py +0 -0
  88. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m7_dispatch.py +0 -0
  89. {codmap-0.0.4 → codmap-0.0.5}/tests/test_m9_family.py +0 -0
  90. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  91. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c13_grep_vs_graph.py +0 -0
  92. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c16_semantic.py +0 -0
  93. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c20_attribute_edges.py +0 -0
  94. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c21_flat_layout.py +0 -0
  95. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c22_source_visible_refs.py +0 -0
  96. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c23_hard_python.py +0 -0
  97. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c24_test_mapping.py +0 -0
  98. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c25_provenance.py +0 -0
  99. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c26_deep_union.py +0 -0
  100. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c27_config_honesty.py +0 -0
  101. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c28_limit_envelope.py +0 -0
  102. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c29_lazy_imports.py +0 -0
  103. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c2_ctags.py +0 -0
  104. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c30_local_import_calls.py +0 -0
  105. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c30f1_reexport_calls.py +0 -0
  106. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c30f2_check_scope.py +0 -0
  107. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c31_path_origin.py +0 -0
  108. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c32_report_json.py +0 -0
  109. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c3_arch_contract.py +0 -0
  110. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c3_dogfood.py +0 -0
  111. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c4_complexity.py +0 -0
  112. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c5_apidiff.py +0 -0
  113. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c6_pack.py +0 -0
  114. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c7_edge_vocab.py +0 -0
  115. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c8_deadcode.py +0 -0
  116. {codmap-0.0.4 → codmap-0.0.5}/tests/test_r1c9_incremental.py +0 -0
  117. {codmap-0.0.4 → codmap-0.0.5}/tests/test_scip_export.py +0 -0
  118. {codmap-0.0.4 → codmap-0.0.5}/tests/test_scope.py +0 -0
  119. {codmap-0.0.4 → codmap-0.0.5}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.4
3
+ Version: 0.0.5
4
4
  Summary: Static analyzer that turns a package's source into a queryable code graph.
5
5
  Author-email: kogriv <kogriv@gmail.com>
6
6
  License-Expression: MIT
@@ -142,6 +142,21 @@ def _classify_signature(sid: str, old: Node, new: Node) -> list[Change]:
142
142
  if old_p is not None and p.required and not old_p.required:
143
143
  out.append(Change(sid, "param-made-required", BREAKING,
144
144
  f"parameter `{name}` is now required"))
145
+ # R1-C34: a parameter that became keyword-only breaks every positional caller,
146
+ # and the reverse only widens. `_Param.keyword_only` was parsed from the start and
147
+ # never read — and could not have been trusted anyway: until R1-C34 the renderer
148
+ # dropped the `*` marker, so every stored signature parsed as if nothing were
149
+ # keyword-only, and `def f(a, b)` -> `def f(a, *, b)` was a no-op here.
150
+ for name, p in sn.params.items():
151
+ old_p = so.params.get(name)
152
+ if old_p is None:
153
+ continue
154
+ if p.keyword_only and not old_p.keyword_only:
155
+ out.append(Change(sid, "param-made-keyword-only", BREAKING,
156
+ f"parameter `{name}` is now keyword-only"))
157
+ elif old_p.keyword_only and not p.keyword_only:
158
+ out.append(Change(sid, "param-made-positional", INFO,
159
+ f"parameter `{name}` is no longer keyword-only"))
145
160
  # variadic removed (*args / **kwargs that existed).
146
161
  if so.has_vararg and not sn.has_vararg:
147
162
  out.append(Change(sid, "variadic-removed", BREAKING, "`*args` removed"))
@@ -33,7 +33,7 @@ of the code.
33
33
 
34
34
  from __future__ import annotations
35
35
 
36
- from dataclasses import dataclass, field
36
+ from dataclasses import dataclass, field, replace
37
37
  from pathlib import Path
38
38
 
39
39
  from codemap.tomlio import read_toml
@@ -56,6 +56,10 @@ class ArchitectureContract:
56
56
  no_lazy_cycles: bool = False
57
57
  exhaustive: bool = False
58
58
  error: str | None = None
59
+ # R1-C35: the file this contract was looked for in. "No contract found" is only
60
+ # actionable next to *where* we looked — a reader in the wrong directory cannot tell
61
+ # an absent contract from a mislocated one, and both exit 0.
62
+ path: str | None = None
59
63
 
60
64
  def is_empty(self) -> bool:
61
65
  return not (self.layers or self.independent or self.forbidden
@@ -80,10 +84,15 @@ def load_contract(root: str | Path = ".") -> ArchitectureContract:
80
84
  file that will not parse is reported through ``error`` rather than being returned as an
81
85
  absent contract: a typo used to turn a failing gate green (R1-C27).
82
86
  """
83
- data, error = read_toml(Path(root) / "codemap.toml")
87
+ # Absolute on purpose: "not found in codemap.toml" is what the reader already
88
+ # assumed. The whole value of the line is *which* codemap.toml, so it answers with a
89
+ # path that is unambiguous from any working directory. (The graph artifact stays free
90
+ # of absolute paths — D5 — but this is terminal output, not the artifact.)
91
+ path = Path(root).resolve() / "codemap.toml"
92
+ data, error = read_toml(path)
84
93
  if error:
85
- return ArchitectureContract(error=error)
86
- return parse_contract(data.get("architecture", {}))
94
+ return ArchitectureContract(error=error, path=str(path))
95
+ return replace(parse_contract(data.get("architecture", {})), path=str(path))
87
96
 
88
97
 
89
98
  def parse_contract(section: dict) -> ArchitectureContract:
@@ -318,7 +318,14 @@ def _print_query_text(r) -> None:
318
318
  print("defined at:", ", ".join(r["defined_at"]) or "—")
319
319
  for m in r["matches"]:
320
320
  loc = f" — {m['file']}:{m['lines'][0]}" if m.get("file") and m["lines"][0] else ""
321
- print(f" - {m['id']} ({m['kind']}){loc}")
321
+ dep = " [deprecated]" if m.get("deprecated") else ""
322
+ print(f" - {m['id']} ({m['kind']}){loc}{dep}")
323
+ # R1-C33: the declared shape, on the line below the location. A class shows its
324
+ # own constructor under that name — not as if the class had a signature.
325
+ if m.get("signature"):
326
+ print(f" {m['signature']}")
327
+ elif m.get("constructor"):
328
+ print(f" constructor: {m['constructor']}")
322
329
  for mid, dep in r.get("modules", {}).items():
323
330
  print(f"\n[{mid}]")
324
331
  print(" imports:", ", ".join(dep["dependencies"]) or "—")
@@ -68,7 +68,28 @@ def build_structural(package_path: str | Path):
68
68
 
69
69
  module_name = pkg_dir.name
70
70
  search_path = pkg_dir.parent
71
- root = griffe.load(module_name, search_paths=[str(search_path)])
71
+ # R1-C36: `try_relative_path` (griffe's default) reinterprets the module *name* as a
72
+ # path relative to the current directory, and that wins over `search_paths`. Run from
73
+ # a repo whose root holds `pkg/`, `build /elsewhere/pkg` then silently analysed the
74
+ # local `pkg` — same shape of answer, different code. We always know the directory we
75
+ # were handed, so the name must resolve through `search_paths` and nowhere else.
76
+ root = griffe.load(module_name, search_paths=[str(search_path)], try_relative_path=False)
77
+ # Defence in depth: whatever the finder does next (a .pth file, a namespace package,
78
+ # a future default), a graph must describe the directory that was asked for. A wrong
79
+ # answer here is invisible downstream — it is well-formed, complete and about the
80
+ # wrong tree — so it fails loudly instead.
81
+ loaded = getattr(root, "filepath", None)
82
+ # A namespace package reports a *list* of directories, not one path — the very shape
83
+ # that once crashed the extractor (issue #4). Handle it here rather than assume.
84
+ candidates = loaded if isinstance(loaded, (list, tuple)) else ([loaded] if loaded else [])
85
+ dirs = {(q if q.is_dir() else q.parent).resolve() for q in map(Path, candidates)}
86
+ if dirs and pkg_dir not in dirs:
87
+ raise ValueError(
88
+ f"resolved `{module_name}` to {', '.join(sorted(str(d) for d in dirs))} but was "
89
+ f"asked for {pkg_dir}. A same-named package is shadowing the target (an "
90
+ "installed copy, or one in the current directory). Build from a different "
91
+ "working directory, or rename the target."
92
+ )
72
93
 
73
94
  graph = Graph(target=module_name)
74
95
  walk = _Walk()
@@ -516,17 +537,58 @@ def _registry_binding(obj) -> dict | None:
516
537
  return None
517
538
 
518
539
 
540
+ def _param(p, *, bare: bool = False) -> str:
541
+ """One parameter, PEP8-spaced: ``x=1`` bare, ``x: int = 1`` annotated.
542
+
543
+ ``bare`` is for ``*args`` / ``**kwargs``, where griffe reports a default of
544
+ ``()`` / ``{}``. That default is the collection the callee receives, not
545
+ something written in the source, and printing it invents an argument.
546
+ """
547
+ s = p.name
548
+ if p.annotation is not None:
549
+ s += f": {p.annotation}"
550
+ if not bare and p.default is not None:
551
+ s += f" = {p.default}" if p.annotation is not None else f"={p.default}"
552
+ return s
553
+
554
+
519
555
  def _signature(obj) -> str | None:
556
+ """The declared signature, as written — including parameter *kind*.
557
+
558
+ R1-C34. The first version of this dropped kind entirely: `*args` came out as a
559
+ parameter named `args` with a default of `()`, `**kw` as `kw={}`, and both `/`
560
+ and the bare `*` marker vanished. So `def h(*args, **kw)` — accepts anything —
561
+ and `def h(args=(), kw={})` — two optional positionals — rendered to the same
562
+ string, and `def f(a, b)` -> `def f(a, *, b)`, which breaks every positional
563
+ caller, was invisible to `apidiff` because both sides rendered `f(a, b)`.
564
+
565
+ The string is consumed by `report api-surface`, `apidiff` (which re-parses it as
566
+ `def <sig>: ...`), the exports, and — since R1-C33 — the `query` dossier. All
567
+ four were reading a signature that could not be called.
568
+ """
520
569
  if obj.kind.value != "function":
521
570
  return None
522
- parts = []
523
- for p in obj.parameters:
524
- s = p.name
525
- if p.annotation is not None:
526
- s += f": {p.annotation}"
527
- if p.default is not None:
528
- s += f" = {p.default}"
529
- parts.append(s)
571
+ params = list(obj.parameters)
572
+ kinds = [getattr(p.kind, "name", "positional_or_keyword") for p in params]
573
+ parts: list[str] = []
574
+ star = False # a `*` or `*args` is already in scope, so `*` must not repeat
575
+ for i, (p, kind) in enumerate(zip(params, kinds)):
576
+ if kind != "positional_only" and i and kinds[i - 1] == "positional_only":
577
+ parts.append("/")
578
+ if kind == "var_positional":
579
+ parts.append("*" + _param(p, bare=True))
580
+ star = True
581
+ elif kind == "var_keyword":
582
+ parts.append("**" + _param(p, bare=True))
583
+ elif kind == "keyword_only":
584
+ if not star:
585
+ parts.append("*")
586
+ star = True
587
+ parts.append(_param(p))
588
+ else:
589
+ parts.append(_param(p))
590
+ if kinds and kinds[-1] == "positional_only":
591
+ parts.append("/")
530
592
  sig = f"{obj.name}({', '.join(parts)})"
531
593
  if obj.returns is not None:
532
594
  sig += f" -> {obj.returns}"
@@ -56,6 +56,9 @@ def build_check(query, contract: ArchitectureContract, violations: list[Violatio
56
56
  "target": query.graph.target,
57
57
  "contract_empty": contract.is_empty(),
58
58
  "contract_error": contract.error,
59
+ # R1-C35: where the contract was looked for. A machine reader of `contract_empty`
60
+ # has the same "absent or mislocated?" question a human does.
61
+ "contract_path": contract.path,
59
62
  "ok": not violations and contract.error is None,
60
63
  "violations": [
61
64
  {"rule": v.rule, "summary": v.summary,
@@ -73,14 +76,21 @@ def render_check(query, contract: ArchitectureContract, violations: list[Violati
73
76
  # reported as a contract that does not exist, which is how one missing `]` turned a
74
77
  # failing gate green. Nothing was enforced either way — but only one of the two is the
75
78
  # user's decision, and the caller exits non-zero on this one.
79
+ where = f"`{contract.path}`" if contract.path else "`codemap.toml`"
76
80
  if contract.error:
77
81
  return (f"# Architecture check — `{target}`\n\n"
78
82
  f"❌ **Contract not read — nothing was enforced.** {contract.error}\n\n"
79
- "_Fix `codemap.toml` (or remove it) and run again. This is a failure, not "
83
+ f"_Fix {where} (or remove it) and run again. This is a failure, not "
80
84
  "an absent contract: rules may exist that no rule-check ran against._\n")
81
85
  if contract.is_empty():
86
+ # R1-C35: name the file we looked in, and name the flag that turns this into a
87
+ # failure. Without the path, a run from the wrong directory is indistinguishable
88
+ # from a project that has no contract; without the flag, the same output is a
89
+ # green CI step that enforced nothing.
82
90
  return (f"# Architecture check — `{target}`\n\n"
83
- "_No `[architecture]` contract found in codemap.toml — nothing to enforce._\n")
91
+ f"_No `[architecture]` contract found in {where} — nothing to enforce, "
92
+ "and this exits 0. Use `--require-contract` to make a missing contract a "
93
+ "failure, or `--root DIR` if the file lives elsewhere._\n")
84
94
  if not violations:
85
95
  rules = []
86
96
  if contract.layers:
@@ -121,9 +121,15 @@ def build_mcp_server(session: "Session", name: str = "codemap") -> Any:
121
121
 
122
122
  @server.tool()
123
123
  def query(name: str) -> dict:
124
- """Full dossier for a symbol name: where defined (file:line), bases/impls for
124
+ """Full dossier for a symbol name: where defined (file:line) with the declared
125
+ `signature` (a class carries its own `constructor` instead), bases/impls for
125
126
  classes, callers/callees/columns for functions, registration recipe, column
126
- dataflow. The main lookup."""
127
+ dataflow. The main lookup.
128
+
129
+ `signature` is how the symbol is declared; `call_contract` is how it is called
130
+ in fact (posargs / kwargs / splat per call site) — ask that one for the second
131
+ question, not for the first. A missing field means the graph has nothing there,
132
+ never that it was not looked up."""
127
133
  return op("query", {"name": name})
128
134
 
129
135
  @server.tool()
@@ -67,6 +67,36 @@ _UNLIMITED_BY_DESIGN = {
67
67
  }
68
68
 
69
69
 
70
+ def _match(q: Query, n) -> dict:
71
+ """One entry of the ``matches`` list: where the symbol is, and how it is declared.
72
+
73
+ R1-C33. The question after "where is it" is "how is it called", and the declared
74
+ signature was already on the node — asking ``call_contract`` for it was a second
75
+ round-trip for something the graph carried all along. Fields that would say nothing
76
+ are omitted rather than emitted as ``null``: a caller can tell "no signature here"
77
+ from "a signature we did not look up" only if the absent case is absent.
78
+
79
+ Note the two are different questions and keep different names. ``signature`` is how
80
+ the symbol is *declared*; ``call_contract`` answers how it is *called in fact*
81
+ (posargs / kwargs / splat per call site), which no node field can know.
82
+ """
83
+ e = {"id": n.id, "kind": n.kind, "file": n.file, "lines": [n.lineno, n.endlineno]}
84
+ if n.signature:
85
+ e["signature"] = n.signature
86
+ elif n.kind == "class":
87
+ # A class has no signature of its own in the model (only functions get one).
88
+ # Its constructor answers what a caller standing on the class actually wants,
89
+ # so it travels under its own name — never dressed up as the class's signature.
90
+ # Only the class's *own* __init__: an inherited one is not resolved here, and
91
+ # for that case saying nothing is the honest answer (unknown != none).
92
+ init = q.graph.nodes.get(f"{n.id}.__init__")
93
+ if init is not None and init.signature:
94
+ e["constructor"] = init.signature
95
+ if n.is_deprecated:
96
+ e["deprecated"] = True
97
+ return e
98
+
99
+
70
100
  def build_query_result(q: Query, name: str) -> dict:
71
101
  """The full symbol dossier — shared by ``codemap query`` and warm serve."""
72
102
  matches = q.find(name)
@@ -75,8 +105,7 @@ def build_query_result(q: Query, name: str) -> dict:
75
105
  "defined_at": q.where_defined(name),
76
106
  # F12: carry file:line so an agent can jump to source; the id here is the
77
107
  # canonical node (not a re-export), so it also chains into relational ops.
78
- "matches": [{"id": n.id, "kind": n.kind, "file": n.file,
79
- "lines": [n.lineno, n.endlineno]} for n in matches],
108
+ "matches": [_match(q, n) for n in matches],
80
109
  }
81
110
  modules = [n.id for n in matches if n.kind == "module"]
82
111
  if modules:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.4
3
+ Version: 0.0.5
4
4
  Summary: Static analyzer that turns a package's source into a queryable code graph.
5
5
  Author-email: kogriv <kogriv@gmail.com>
6
6
  License-Expression: MIT
@@ -100,6 +100,10 @@ tests/test_r1c30f1_reexport_calls.py
100
100
  tests/test_r1c30f2_check_scope.py
101
101
  tests/test_r1c31_path_origin.py
102
102
  tests/test_r1c32_report_json.py
103
+ tests/test_r1c33_query_signature.py
104
+ tests/test_r1c34_signature_kinds.py
105
+ tests/test_r1c35_contract_location.py
106
+ tests/test_r1c36_target_resolution.py
103
107
  tests/test_r1c3_arch_contract.py
104
108
  tests/test_r1c3_dogfood.py
105
109
  tests/test_r1c4_complexity.py
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
9
9
  # version. README says this plainly, since `pip install codmap` / `import codemap` is a
10
10
  # seam a reader will otherwise hit by surprise.
11
11
  name = "codmap"
12
- version = "0.0.4"
12
+ version = "0.0.5"
13
13
  description = "Static analyzer that turns a package's source into a queryable code graph."
14
14
  readme = "README.md"
15
15
  # M20/D1: measured, not assumed. Each release of the declared range runs the full suite in
@@ -0,0 +1,189 @@
1
+ """R1-C33 — the declared signature travels with the dossier.
2
+
3
+ Not a defect report: this came from auditing the "what we'd take" list in the CodeGraph
4
+ разбор (`research/tools/codegraph.md`) and finding that three of its four items had been
5
+ closed while item 2 had never been carded at all. It sat for three days as an invisible
6
+ line inside prose, because a list without status reads as done — the same blindness
7
+ R1-C28 is about, turned on our own notes instead of an answer envelope.
8
+
9
+ The substance is small and one-directional: a caller who has just learned *where* a
10
+ symbol is asks next *how it is called*, and `Node.signature` was already in the graph
11
+ (`serve/api_surface.py` reads it). Getting it meant a second op — `call_contract` — which
12
+ answers a different question.
13
+
14
+ Two lines this file holds:
15
+
16
+ * **`signature` (declared) is not `call_contract` (called in fact).** The first is a node
17
+ field; the second is per-call-site argument shape. Keeping the names apart is the point.
18
+ * **Absent means absent.** A node with nothing to say omits the key rather than emitting
19
+ `null` — a class has no signature of its own (only functions get one), so it carries its
20
+ own `__init__` under `constructor`, and an *inherited* constructor is not resolved here.
21
+ Saying nothing about it is the honest answer (unknown != none), and this file pins that
22
+ rather than leaving it to be "fixed" later by a guess.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import subprocess
29
+ import sys
30
+
31
+ import pytest
32
+
33
+ from codemap.extract import extract
34
+ from codemap.query import Query
35
+ from codemap.serve.session import Session, build_query_result
36
+ from codemap.store import save
37
+
38
+ SRC = {
39
+ "__init__.py": "",
40
+ "shapes.py": (
41
+ '"""Shapes."""\n'
42
+ "\n"
43
+ "\n"
44
+ "class Base:\n"
45
+ " def __init__(self, name: str, size: int = 1) -> None:\n"
46
+ " self.name = name\n"
47
+ " self.size = size\n"
48
+ "\n"
49
+ "\n"
50
+ "class Derived(Base):\n"
51
+ ' """No __init__ of its own — the constructor is inherited."""\n'
52
+ "\n"
53
+ " def area(self) -> float:\n"
54
+ " return 0.0\n"
55
+ ),
56
+ "api.py": (
57
+ '"""Api."""\n'
58
+ "\n"
59
+ "from warnings import deprecated\n"
60
+ "\n"
61
+ "\n"
62
+ "def run(target: str, *, retries: int = 3) -> bool:\n"
63
+ ' """Do the thing."""\n'
64
+ " return bool(target) and retries > 0\n"
65
+ "\n"
66
+ "\n"
67
+ "@deprecated('use run')\n"
68
+ "def run_old(target):\n"
69
+ " return run(target)\n"
70
+ ),
71
+ }
72
+
73
+
74
+ @pytest.fixture(scope="module")
75
+ def pkg(tmp_path_factory):
76
+ root = tmp_path_factory.mktemp("src") / "pkg"
77
+ root.mkdir()
78
+ for name, src in SRC.items():
79
+ (root / name).write_text(src)
80
+ return root
81
+
82
+
83
+ @pytest.fixture(scope="module")
84
+ def graph(pkg):
85
+ return extract(str(pkg))
86
+
87
+
88
+ @pytest.fixture(scope="module")
89
+ def q(graph):
90
+ return Query(graph)
91
+
92
+
93
+ def _match(q, name, node_id=None):
94
+ ms = build_query_result(q, name)["matches"]
95
+ if node_id:
96
+ ms = [m for m in ms if m["id"] == node_id]
97
+ assert ms, f"no match for {name}"
98
+ return ms[0]
99
+
100
+
101
+ # -- the declared signature --------------------------------------------------
102
+
103
+ def test_a_function_carries_its_declared_signature(q):
104
+ m = _match(q, "run")
105
+ assert m["signature"] == "run(target: str, *, retries: int = 3) -> bool"
106
+
107
+
108
+ def test_the_signature_is_the_node_field_not_a_recomputation(q, graph):
109
+ """If these ever diverge, one of the two is inventing something."""
110
+ m = _match(q, "run")
111
+ assert m["signature"] == graph.nodes["pkg.api.run"].signature
112
+
113
+
114
+ def test_annotations_and_defaults_survive(q):
115
+ """The half a caller actually needs: it is not just the parameter names."""
116
+ sig = _match(q, "run")["signature"]
117
+ assert ": str" in sig and "retries: int = 3" in sig and "-> bool" in sig
118
+
119
+
120
+ # -- absent means absent -----------------------------------------------------
121
+
122
+ def test_a_module_has_no_signature_key_at_all(q):
123
+ m = _match(q, "api", node_id="pkg.api")
124
+ assert m["kind"] == "module"
125
+ assert "signature" not in m and "constructor" not in m
126
+
127
+
128
+ def test_a_class_reports_its_own_constructor_under_its_own_name(q):
129
+ m = _match(q, "Base")
130
+ assert m["kind"] == "class"
131
+ # Never dressed up as the class's signature — the class has none.
132
+ assert "signature" not in m
133
+ assert m["constructor"] == "__init__(self, name: str, size: int = 1) -> None"
134
+
135
+
136
+ def test_an_inherited_constructor_is_not_claimed(q):
137
+ """`Derived` has no `__init__` of its own. We do not walk bases to find one, and
138
+ guessing would be worse than the silence: the honest answer is no field."""
139
+ m = _match(q, "Derived")
140
+ assert "constructor" not in m and "signature" not in m
141
+
142
+
143
+ def test_no_field_is_ever_null(q):
144
+ for name in ("run", "Base", "Derived", "api", "area"):
145
+ for m in build_query_result(q, name)["matches"]:
146
+ assert m.get("signature", "x") is not None
147
+ assert m.get("constructor", "x") is not None
148
+
149
+
150
+ # -- deprecation, since it is on the same node -------------------------------
151
+
152
+ def test_a_deprecated_symbol_says_so(q):
153
+ assert _match(q, "run_old").get("deprecated") is True
154
+
155
+
156
+ def test_a_live_symbol_carries_no_deprecated_key(q):
157
+ assert "deprecated" not in _match(q, "run")
158
+
159
+
160
+ # -- one builder: CLI, warm serve and MCP cannot drift apart -----------------
161
+
162
+ def test_warm_serve_and_the_cli_return_the_same_dossier(graph, q, tmp_path):
163
+ out = tmp_path / "g.json"
164
+ save(graph, str(out))
165
+ cli = subprocess.run([sys.executable, "-m", "codemap.cli", "query", "run",
166
+ "--graph", str(out), "--format", "json"],
167
+ capture_output=True, text=True)
168
+ assert cli.returncode == 0, cli.stderr
169
+ served = Session(graph).handle({"op": "query", "args": {"name": "run"}})
170
+ assert served["ok"]
171
+ assert json.loads(cli.stdout)["matches"] == served["result"]["matches"]
172
+
173
+
174
+ def test_the_text_form_prints_the_signature_and_labels_a_constructor(graph, tmp_path):
175
+ out = tmp_path / "g.json"
176
+ save(graph, str(out))
177
+
178
+ def run_text(name):
179
+ r = subprocess.run([sys.executable, "-m", "codemap.cli", "query", name,
180
+ "--graph", str(out), "--format", "text"],
181
+ capture_output=True, text=True)
182
+ assert r.returncode == 0, r.stderr
183
+ return r.stdout
184
+
185
+ text = run_text("run")
186
+ assert '"signature"' not in text, "this must exercise the text renderer, not json"
187
+ assert " run(target: str, *, retries: int = 3) -> bool" in text
188
+ assert "constructor: __init__(self, name: str, size: int = 1) -> None" in run_text("Base")
189
+ assert "[deprecated]" in run_text("run_old")
@@ -0,0 +1,150 @@
1
+ """R1-C34 — the stored signature keeps parameter *kind*, so it can be called.
2
+
3
+ Found while doing R1-C33 (putting the declared signature into the `query` dossier): the
4
+ first test asserted the signature of `def run(target: str, *, retries: int = 3) -> bool`
5
+ and got back `run(target: str, retries: int = 3) -> bool`. The renderer dropped kind
6
+ entirely, and had since M0:
7
+
8
+ def f(a, /, b, *args, c: int = 1, **kw) -> f(a, b, args = (), c: int = 1, kw = {})
9
+ def h(*args, **kw) -> h(args = (), kw = {})
10
+
11
+ Two different failures in one string. The `*` and `/` markers vanish, so a keyword-only
12
+ parameter reads as positional — write the call the signature describes and Python raises
13
+ TypeError. And the variadics come back as ordinary parameters carrying the *runtime*
14
+ default griffe reports (`()` / `{}`), which is not written anywhere in the source: a
15
+ function that accepts anything renders as one taking two optional positionals.
16
+
17
+ It mattered beyond cosmetics because three consumers read this string as if it were the
18
+ declaration: `report api-surface`, the exports, and `apidiff`, which re-parses it as
19
+ `def <sig>: …`. In `apidiff` the loss silently disabled three of its own rules —
20
+ `has_vararg`, `has_kwarg` and `_Param.keyword_only` could never be true, so "`*args`
21
+ removed" was unreachable, and `def f(a, b)` → `def f(a, *, b)`, which breaks every
22
+ positional caller, was classified as no change at all. The data to catch it had been
23
+ parsed all along and could not be trusted, which is why the rule that reads it lands
24
+ here rather than in its own change.
25
+
26
+ The strong test in this file is the round-trip: render, re-parse, and compare parameter
27
+ kinds against the source. A signature that does not survive that is not a declaration,
28
+ it is a description of one.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import ast
34
+
35
+ import pytest
36
+
37
+ from codemap.apidiff import BREAKING, INFO, diff_api
38
+ from codemap.extract import extract
39
+ from codemap.model import Graph, Node
40
+
41
+ # (source line, expected rendering) — the shapes that carry kind.
42
+ CASES = [
43
+ ("def plain(a, b=2): ...", "plain(a, b=2)"),
44
+ ("def annotated(a: int, b: str = 'x') -> bool: ...", "annotated(a: int, b: str = 'x') -> bool"),
45
+ ("def kwonly(a, *, b=2): ...", "kwonly(a, *, b=2)"),
46
+ ("def kwonly_required(*, only): ...", "kwonly_required(*, only)"),
47
+ ("def posonly(a, /): ...", "posonly(a, /)"),
48
+ ("def posonly_then(a, /, b): ...", "posonly_then(a, /, b)"),
49
+ ("def variadic(*args, **kw): ...", "variadic(*args, **kw)"),
50
+ ("def variadic_typed(*args: int, **kw: str) -> None: ...", "variadic_typed(*args: int, **kw: str) -> None"),
51
+ ("def everything(a, /, b, *args, c: int = 1, **kw) -> None: ...",
52
+ "everything(a, /, b, *args, c: int = 1, **kw) -> None"),
53
+ ("def star_then_kwarg(*args, c=1, **kw): ...", "star_then_kwarg(*args, c=1, **kw)"),
54
+ ]
55
+
56
+
57
+ @pytest.fixture(scope="module")
58
+ def rendered(tmp_path_factory):
59
+ pkg = tmp_path_factory.mktemp("src") / "pkg"
60
+ pkg.mkdir()
61
+ (pkg / "__init__.py").write_text("")
62
+ (pkg / "api.py").write_text("\n".join(src for src, _ in CASES) + "\n")
63
+ graph = extract(str(pkg))
64
+ return {n.id.rsplit(".", 1)[-1]: n.signature
65
+ for n in graph.nodes.values() if n.kind == "function"}
66
+
67
+
68
+ def _kinds(sig_or_def: str) -> list[tuple[str, str]]:
69
+ """(kind, name) per parameter, from a `def` statement."""
70
+ fn = ast.parse(sig_or_def if sig_or_def.startswith("def ") else f"def {sig_or_def}: ...").body[0]
71
+ a = fn.args
72
+ out = [("positional_only", p.arg) for p in a.posonlyargs]
73
+ out += [("positional_or_keyword", p.arg) for p in a.args]
74
+ if a.vararg:
75
+ out.append(("var_positional", a.vararg.arg))
76
+ out += [("keyword_only", p.arg) for p in a.kwonlyargs]
77
+ if a.kwarg:
78
+ out.append(("var_keyword", a.kwarg.arg))
79
+ return out
80
+
81
+
82
+ @pytest.mark.parametrize("src,expected", CASES, ids=[s.split("(")[0][4:] for s, _ in CASES])
83
+ def test_the_signature_renders_as_written(rendered, src, expected):
84
+ name = src.split("(")[0][len("def "):]
85
+ assert rendered[name] == expected
86
+
87
+
88
+ @pytest.mark.parametrize("src,_expected", CASES, ids=[s.split("(")[0][4:] for s, _ in CASES])
89
+ def test_round_trip_preserves_every_parameter_kind(rendered, src, _expected):
90
+ """Render → re-parse → the kinds are the source's. This is the property; the exact
91
+ strings above are only how it happens to be spelled."""
92
+ name = src.split("(")[0][len("def "):]
93
+ assert _kinds(rendered[name]) == _kinds(src)
94
+
95
+
96
+ @pytest.mark.parametrize("src,_expected", CASES, ids=[s.split("(")[0][4:] for s, _ in CASES])
97
+ def test_every_signature_parses_as_a_def(rendered, src, _expected):
98
+ """apidiff's contract: it reads the stored string back as `def <sig>: ...`."""
99
+ name = src.split("(")[0][len("def "):]
100
+ ast.parse(f"def {rendered[name]}: ...")
101
+
102
+
103
+ def test_a_variadic_does_not_acquire_an_invented_default(rendered):
104
+ """griffe reports `()` / `{}` for *args / **kw — the collection the callee receives,
105
+ not something written in the source."""
106
+ assert "= ()" not in rendered["variadic"] and "= {}" not in rendered["variadic"]
107
+ assert rendered["variadic"] == "variadic(*args, **kw)"
108
+
109
+
110
+ def test_accepting_anything_does_not_look_like_two_optional_positionals(rendered):
111
+ """The old rendering of `def h(*args, **kw)` was `h(args = (), kw = {})` — a
112
+ different function, and a callable-looking one."""
113
+ assert _kinds(rendered["variadic"]) == [("var_positional", "args"), ("var_keyword", "kw")]
114
+
115
+
116
+ def test_pep8_spacing_annotated_and_bare(rendered):
117
+ assert "b=2" in rendered["plain"] # bare default: no spaces
118
+ assert "b: str = 'x'" in rendered["annotated"] # annotated: spaces
119
+
120
+
121
+ # -- the consumer that could not see it --------------------------------------
122
+
123
+ def _g(sig: str) -> Graph:
124
+ g = Graph(target="pkg")
125
+ g.add_node(Node(id="pkg.f", kind="function", file="pkg/api.py", signature=sig))
126
+ return g
127
+
128
+
129
+ def test_making_a_parameter_keyword_only_is_breaking():
130
+ d = diff_api(_g("f(a, b)"), _g("f(a, *, b)"))
131
+ kinds = {(c.kind, c.severity) for c in d.changes}
132
+ assert ("param-made-keyword-only", BREAKING) in kinds
133
+
134
+
135
+ def test_dropping_the_keyword_only_marker_only_widens():
136
+ d = diff_api(_g("f(a, *, b)"), _g("f(a, b)"))
137
+ kinds = {(c.kind, c.severity) for c in d.changes}
138
+ assert ("param-made-positional", INFO) in kinds
139
+ assert not any(c.severity == BREAKING for c in d.changes)
140
+
141
+
142
+ def test_removing_a_variadic_is_now_reachable():
143
+ """`has_vararg` was dead: nothing ever rendered a `*`, so it was False on both sides
144
+ of every real diff."""
145
+ d = diff_api(_g("f(a, *args)"), _g("f(a)"))
146
+ assert ("variadic-removed", BREAKING) in {(c.kind, c.severity) for c in d.changes}
147
+
148
+
149
+ def test_an_unchanged_signature_is_still_silent():
150
+ assert diff_api(_g("f(a, /, b, *args, c=1, **kw)"), _g("f(a, /, b, *args, c=1, **kw)")).changes == []