codmap 0.0.4__tar.gz → 0.0.6__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 (121) hide show
  1. {codmap-0.0.4 → codmap-0.0.6}/PKG-INFO +2 -2
  2. {codmap-0.0.4 → codmap-0.0.6}/README.md +1 -1
  3. {codmap-0.0.4 → codmap-0.0.6}/codemap/apidiff.py +15 -0
  4. {codmap-0.0.4 → codmap-0.0.6}/codemap/arch.py +13 -4
  5. {codmap-0.0.4 → codmap-0.0.6}/codemap/cli.py +8 -1
  6. {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/griffe_extractor.py +84 -9
  7. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/check.py +12 -2
  8. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/mcp_server.py +8 -2
  9. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/session.py +80 -7
  10. {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/PKG-INFO +2 -2
  11. {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/SOURCES.txt +6 -0
  12. {codmap-0.0.4 → codmap-0.0.6}/pyproject.toml +1 -1
  13. codmap-0.0.6/tests/test_r1c33_query_signature.py +189 -0
  14. codmap-0.0.6/tests/test_r1c34_signature_kinds.py +150 -0
  15. codmap-0.0.6/tests/test_r1c35_contract_location.py +101 -0
  16. codmap-0.0.6/tests/test_r1c36_target_resolution.py +175 -0
  17. codmap-0.0.6/tests/test_r1c37_rule_mutation.py +178 -0
  18. codmap-0.0.6/tests/test_r1c38_tool_drift.py +75 -0
  19. {codmap-0.0.4 → codmap-0.0.6}/LICENSE +0 -0
  20. {codmap-0.0.4 → codmap-0.0.6}/codemap/__init__.py +0 -0
  21. {codmap-0.0.4 → codmap-0.0.6}/codemap/diagnostics.py +0 -0
  22. {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/__init__.py +0 -0
  23. {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/attrflow.py +0 -0
  24. {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/behavior.py +0 -0
  25. {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/dataflow.py +0 -0
  26. {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/dispatch.py +0 -0
  27. {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/gsource.py +0 -0
  28. {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/roots.py +0 -0
  29. {codmap-0.0.4 → codmap-0.0.6}/codemap/freshness.py +0 -0
  30. {codmap-0.0.4 → codmap-0.0.6}/codemap/incremental.py +0 -0
  31. {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/__init__.py +0 -0
  32. {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/base.py +0 -0
  33. {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/cocoindex.py +0 -0
  34. {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/gate.py +0 -0
  35. {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/gitnexus.py +0 -0
  36. {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/registry.py +0 -0
  37. {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/transport.py +0 -0
  38. {codmap-0.0.4 → codmap-0.0.6}/codemap/model.py +0 -0
  39. {codmap-0.0.4 → codmap-0.0.6}/codemap/provenance.py +0 -0
  40. {codmap-0.0.4 → codmap-0.0.6}/codemap/query.py +0 -0
  41. {codmap-0.0.4 → codmap-0.0.6}/codemap/scope.py +0 -0
  42. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/__init__.py +0 -0
  43. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/_scip_pb2.py +0 -0
  44. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/api_surface.py +0 -0
  45. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/apidiff.py +0 -0
  46. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/architecture.py +0 -0
  47. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/audit.py +0 -0
  48. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/ctags.py +0 -0
  49. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/impact.py +0 -0
  50. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/limits.py +0 -0
  51. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/livingdocs.py +0 -0
  52. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/mermaid.py +0 -0
  53. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/pack.py +0 -0
  54. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/rag.py +0 -0
  55. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/review.py +0 -0
  56. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/scip.py +0 -0
  57. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/semantic.py +0 -0
  58. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/server.py +0 -0
  59. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/subsystems.py +0 -0
  60. {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/vault.py +0 -0
  61. {codmap-0.0.4 → codmap-0.0.6}/codemap/store.py +0 -0
  62. {codmap-0.0.4 → codmap-0.0.6}/codemap/tomlio.py +0 -0
  63. {codmap-0.0.4 → codmap-0.0.6}/codemap/watch.py +0 -0
  64. {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/dependency_links.txt +0 -0
  65. {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/entry_points.txt +0 -0
  66. {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/requires.txt +0 -0
  67. {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/top_level.txt +0 -0
  68. {codmap-0.0.4 → codmap-0.0.6}/setup.cfg +0 -0
  69. {codmap-0.0.4 → codmap-0.0.6}/tests/test_epistemic.py +0 -0
  70. {codmap-0.0.4 → codmap-0.0.6}/tests/test_gitnexus_router.py +0 -0
  71. {codmap-0.0.4 → codmap-0.0.6}/tests/test_impact_depth.py +0 -0
  72. {codmap-0.0.4 → codmap-0.0.6}/tests/test_integrations.py +0 -0
  73. {codmap-0.0.4 → codmap-0.0.6}/tests/test_issue3_serve_freshness.py +0 -0
  74. {codmap-0.0.4 → codmap-0.0.6}/tests/test_livingdocs.py +0 -0
  75. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m0_api_surface.py +0 -0
  76. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m11_argcontract.py +0 -0
  77. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m12_dataflow.py +0 -0
  78. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m15_review.py +0 -0
  79. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m16_architecture.py +0 -0
  80. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m17_mcp.py +0 -0
  81. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m18_freshness.py +0 -0
  82. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m1_5_semantics.py +0 -0
  83. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m1_query.py +0 -0
  84. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m2_views.py +0 -0
  85. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m32_watch.py +0 -0
  86. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m3_serve.py +0 -0
  87. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m4_behavior.py +0 -0
  88. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m5_deep.py +0 -0
  89. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m6_repo_scope.py +0 -0
  90. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m7_dispatch.py +0 -0
  91. {codmap-0.0.4 → codmap-0.0.6}/tests/test_m9_family.py +0 -0
  92. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  93. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c13_grep_vs_graph.py +0 -0
  94. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c16_semantic.py +0 -0
  95. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c20_attribute_edges.py +0 -0
  96. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c21_flat_layout.py +0 -0
  97. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c22_source_visible_refs.py +0 -0
  98. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c23_hard_python.py +0 -0
  99. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c24_test_mapping.py +0 -0
  100. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c25_provenance.py +0 -0
  101. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c26_deep_union.py +0 -0
  102. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c27_config_honesty.py +0 -0
  103. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c28_limit_envelope.py +0 -0
  104. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c29_lazy_imports.py +0 -0
  105. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c2_ctags.py +0 -0
  106. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c30_local_import_calls.py +0 -0
  107. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c30f1_reexport_calls.py +0 -0
  108. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c30f2_check_scope.py +0 -0
  109. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c31_path_origin.py +0 -0
  110. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c32_report_json.py +0 -0
  111. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c3_arch_contract.py +0 -0
  112. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c3_dogfood.py +0 -0
  113. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c4_complexity.py +0 -0
  114. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c5_apidiff.py +0 -0
  115. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c6_pack.py +0 -0
  116. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c7_edge_vocab.py +0 -0
  117. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c8_deadcode.py +0 -0
  118. {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c9_incremental.py +0 -0
  119. {codmap-0.0.4 → codmap-0.0.6}/tests/test_scip_export.py +0 -0
  120. {codmap-0.0.4 → codmap-0.0.6}/tests/test_scope.py +0 -0
  121. {codmap-0.0.4 → codmap-0.0.6}/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.6
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
@@ -44,7 +44,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
44
44
 
45
45
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
46
46
 
47
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **655 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **732 tests with no failures on
48
48
  Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
49
49
  wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
50
50
  as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
@@ -8,7 +8,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
8
8
 
9
9
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
10
10
 
11
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **655 tests with no failures on
11
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **732 tests with no failures on
12
12
  Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
13
13
  wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
14
14
  as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
@@ -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 "—")
@@ -54,6 +54,30 @@ class _Walk:
54
54
  aliased: list = field(default_factory=list) # (skipped_id, owner_id)
55
55
 
56
56
 
57
+ def _assert_is_the_target(loaded, pkg_dir: Path, module_name: str) -> None:
58
+ """Raise unless what griffe loaded is the directory we were handed (R1-C36).
59
+
60
+ Separate from its caller so the decision can be tested on shapes that a single
61
+ ``search_paths`` entry cannot produce end to end. A **namespace package reports a
62
+ list** of directories rather than one path — the shape that once crashed the
63
+ extractor (issue #4) — and a package assembled from several parts is a legitimate
64
+ hit as long as the requested directory is one of them.
65
+
66
+ An empty ``loaded`` is not treated as a mismatch: "griffe told us nothing about the
67
+ file" is not "griffe told us the wrong file", and inventing a failure from silence
68
+ is the same error in the other direction.
69
+ """
70
+ candidates = loaded if isinstance(loaded, (list, tuple)) else ([loaded] if loaded else [])
71
+ dirs = {(q if q.is_dir() else q.parent).resolve() for q in map(Path, candidates)}
72
+ if dirs and pkg_dir not in dirs:
73
+ raise ValueError(
74
+ f"resolved `{module_name}` to {', '.join(sorted(str(d) for d in dirs))} but was "
75
+ f"asked for {pkg_dir}. A same-named package is shadowing the target (an "
76
+ "installed copy, or one in the current directory). Build from a different "
77
+ "working directory, or rename the target."
78
+ )
79
+
80
+
57
81
  def build_structural(package_path: str | Path):
58
82
  """The cheap, deterministic base: griffe load + definition nodes + structural
59
83
  edges (contains / imports / inherits / decorated_by / export). No behavioral
@@ -68,7 +92,17 @@ def build_structural(package_path: str | Path):
68
92
 
69
93
  module_name = pkg_dir.name
70
94
  search_path = pkg_dir.parent
71
- root = griffe.load(module_name, search_paths=[str(search_path)])
95
+ # R1-C36: `try_relative_path` (griffe's default) reinterprets the module *name* as a
96
+ # path relative to the current directory, and that wins over `search_paths`. Run from
97
+ # a repo whose root holds `pkg/`, `build /elsewhere/pkg` then silently analysed the
98
+ # local `pkg` — same shape of answer, different code. We always know the directory we
99
+ # were handed, so the name must resolve through `search_paths` and nowhere else.
100
+ root = griffe.load(module_name, search_paths=[str(search_path)], try_relative_path=False)
101
+ # Defence in depth: whatever the finder does next (a .pth file, a namespace package,
102
+ # a future default), a graph must describe the directory that was asked for. A wrong
103
+ # answer here is invisible downstream — it is well-formed, complete and about the
104
+ # wrong tree — so it fails loudly instead.
105
+ _assert_is_the_target(getattr(root, "filepath", None), pkg_dir, module_name)
72
106
 
73
107
  graph = Graph(target=module_name)
74
108
  walk = _Walk()
@@ -516,17 +550,58 @@ def _registry_binding(obj) -> dict | None:
516
550
  return None
517
551
 
518
552
 
553
+ def _param(p, *, bare: bool = False) -> str:
554
+ """One parameter, PEP8-spaced: ``x=1`` bare, ``x: int = 1`` annotated.
555
+
556
+ ``bare`` is for ``*args`` / ``**kwargs``, where griffe reports a default of
557
+ ``()`` / ``{}``. That default is the collection the callee receives, not
558
+ something written in the source, and printing it invents an argument.
559
+ """
560
+ s = p.name
561
+ if p.annotation is not None:
562
+ s += f": {p.annotation}"
563
+ if not bare and p.default is not None:
564
+ s += f" = {p.default}" if p.annotation is not None else f"={p.default}"
565
+ return s
566
+
567
+
519
568
  def _signature(obj) -> str | None:
569
+ """The declared signature, as written — including parameter *kind*.
570
+
571
+ R1-C34. The first version of this dropped kind entirely: `*args` came out as a
572
+ parameter named `args` with a default of `()`, `**kw` as `kw={}`, and both `/`
573
+ and the bare `*` marker vanished. So `def h(*args, **kw)` — accepts anything —
574
+ and `def h(args=(), kw={})` — two optional positionals — rendered to the same
575
+ string, and `def f(a, b)` -> `def f(a, *, b)`, which breaks every positional
576
+ caller, was invisible to `apidiff` because both sides rendered `f(a, b)`.
577
+
578
+ The string is consumed by `report api-surface`, `apidiff` (which re-parses it as
579
+ `def <sig>: ...`), the exports, and — since R1-C33 — the `query` dossier. All
580
+ four were reading a signature that could not be called.
581
+ """
520
582
  if obj.kind.value != "function":
521
583
  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)
584
+ params = list(obj.parameters)
585
+ kinds = [getattr(p.kind, "name", "positional_or_keyword") for p in params]
586
+ parts: list[str] = []
587
+ star = False # a `*` or `*args` is already in scope, so `*` must not repeat
588
+ for i, (p, kind) in enumerate(zip(params, kinds)):
589
+ if kind != "positional_only" and i and kinds[i - 1] == "positional_only":
590
+ parts.append("/")
591
+ if kind == "var_positional":
592
+ parts.append("*" + _param(p, bare=True))
593
+ star = True
594
+ elif kind == "var_keyword":
595
+ parts.append("**" + _param(p, bare=True))
596
+ elif kind == "keyword_only":
597
+ if not star:
598
+ parts.append("*")
599
+ star = True
600
+ parts.append(_param(p))
601
+ else:
602
+ parts.append(_param(p))
603
+ if kinds and kinds[-1] == "positional_only":
604
+ parts.append("/")
530
605
  sig = f"{obj.name}({', '.join(parts)})"
531
606
  if obj.returns is not None:
532
607
  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,64 @@ _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
+
100
+ def tool_drift(running: str | None, installed: str | None) -> dict | None:
101
+ """Say when this process is running code the installed distribution has moved past.
102
+
103
+ R1-C38. A warm server holds two things that go stale independently: the graph and the
104
+ **code**. `reload` refreshes the graph, and nothing refreshes the code — a process
105
+ started before an upgrade keeps answering in the shape it was born with, over a graph
106
+ that carries everything the new shape needs. Measured, not imagined: a server started
107
+ before R1-C33 served a freshly rebuilt 0.13 graph and returned dossiers without the
108
+ `signature` the artifact contained, with nothing in the answer to say why.
109
+
110
+ `stats` already separates the graph's schema from the running tool's. This is the same
111
+ separation one level up, and it is only visible because the version is read from the
112
+ installed metadata at call time while the code was loaded at import.
113
+ """
114
+ if not running or not installed or running == installed:
115
+ return None
116
+ return {
117
+ "code": "tool_restart_needed",
118
+ "severity": "warning",
119
+ "running": running,
120
+ "installed": installed,
121
+ "consequence": "Answers keep the shape of the running code; `reload` refreshes the "
122
+ "graph, never the code.",
123
+ "message": f"this server is running codemap {running} while {installed} is installed — "
124
+ "restart it to serve the installed version.",
125
+ }
126
+
127
+
70
128
  def build_query_result(q: Query, name: str) -> dict:
71
129
  """The full symbol dossier — shared by ``codemap query`` and warm serve."""
72
130
  matches = q.find(name)
@@ -75,8 +133,7 @@ def build_query_result(q: Query, name: str) -> dict:
75
133
  "defined_at": q.where_defined(name),
76
134
  # F12: carry file:line so an agent can jump to source; the id here is the
77
135
  # 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],
136
+ "matches": [_match(q, n) for n in matches],
80
137
  }
81
138
  modules = [n.id for n in matches if n.kind == "module"]
82
139
  if modules:
@@ -238,11 +295,21 @@ class Session:
238
295
  # edges from a layout the extractor didn't understand), rather than letting every
239
296
  # downstream conclusion silently inherit the emptiness.
240
297
  from codemap.diagnostics import diagnostics
241
- diags = diagnostics(self.graph)
298
+ diags = list(diagnostics(self.graph))
299
+ # R1-C38: the graph is not the only thing that goes stale in a warm server.
300
+ drift = self._tool_drift()
301
+ if drift:
302
+ diags.append(drift)
242
303
  if diags:
243
304
  out["diagnostics"] = diags
244
305
  return out
245
306
 
307
+ @staticmethod
308
+ def _tool_drift() -> dict | None:
309
+ import codemap
310
+ from codemap.provenance import tool_version
311
+ return tool_drift(getattr(codemap, "__version__", None), tool_version())
312
+
246
313
  def _op_reload(self, args) -> dict:
247
314
  """Reload the on-disk artifact into the served graph, without a restart (#3).
248
315
 
@@ -268,10 +335,16 @@ class Session:
268
335
  self.query = Query(graph)
269
336
  self._served_mtime = self._current_mtime()
270
337
  after = {"nodes": len(graph.nodes), "edges": len(graph.edges)}
271
- return {"reloaded": True, "before": before, "after": after,
272
- "changed": before != after,
273
- "freshness": freshness(self.graph_path,
274
- served_mtime=self._served_mtime)}
338
+ out = {"reloaded": True, "before": before, "after": after,
339
+ "changed": before != after,
340
+ "freshness": freshness(self.graph_path,
341
+ served_mtime=self._served_mtime)}
342
+ # R1-C38: this is the op whose name promises freshness, so it is the op that owes
343
+ # the caller the half it cannot deliver — the code, which only a restart replaces.
344
+ drift = self._tool_drift()
345
+ if drift:
346
+ out["diagnostics"] = [drift]
347
+ return out
275
348
 
276
349
  def _op_query(self, args) -> dict:
277
350
  return build_query_result(self.query, args["name"])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.4
3
+ Version: 0.0.6
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
@@ -44,7 +44,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
44
44
 
45
45
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
46
46
 
47
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **655 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **732 tests with no failures on
48
48
  Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
49
49
  wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
50
50
  as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
@@ -100,6 +100,12 @@ 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
107
+ tests/test_r1c37_rule_mutation.py
108
+ tests/test_r1c38_tool_drift.py
103
109
  tests/test_r1c3_arch_contract.py
104
110
  tests/test_r1c3_dogfood.py
105
111
  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.6"
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")