codmap 0.0.5__tar.gz → 0.0.7__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.5 → codmap-0.0.7}/PKG-INFO +2 -2
  2. {codmap-0.0.5 → codmap-0.0.7}/README.md +1 -1
  3. {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/griffe_extractor.py +25 -12
  4. {codmap-0.0.5 → codmap-0.0.7}/codemap/provenance.py +23 -5
  5. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/session.py +57 -5
  6. {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/PKG-INFO +2 -2
  7. {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/SOURCES.txt +2 -0
  8. {codmap-0.0.5 → codmap-0.0.7}/pyproject.toml +1 -1
  9. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c36_target_resolution.py +68 -0
  10. codmap-0.0.7/tests/test_r1c37_rule_mutation.py +178 -0
  11. codmap-0.0.7/tests/test_r1c38_tool_drift.py +124 -0
  12. {codmap-0.0.5 → codmap-0.0.7}/LICENSE +0 -0
  13. {codmap-0.0.5 → codmap-0.0.7}/codemap/__init__.py +0 -0
  14. {codmap-0.0.5 → codmap-0.0.7}/codemap/apidiff.py +0 -0
  15. {codmap-0.0.5 → codmap-0.0.7}/codemap/arch.py +0 -0
  16. {codmap-0.0.5 → codmap-0.0.7}/codemap/cli.py +0 -0
  17. {codmap-0.0.5 → codmap-0.0.7}/codemap/diagnostics.py +0 -0
  18. {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/__init__.py +0 -0
  19. {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/attrflow.py +0 -0
  20. {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/behavior.py +0 -0
  21. {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/dataflow.py +0 -0
  22. {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/dispatch.py +0 -0
  23. {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/gsource.py +0 -0
  24. {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/roots.py +0 -0
  25. {codmap-0.0.5 → codmap-0.0.7}/codemap/freshness.py +0 -0
  26. {codmap-0.0.5 → codmap-0.0.7}/codemap/incremental.py +0 -0
  27. {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/__init__.py +0 -0
  28. {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/base.py +0 -0
  29. {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/cocoindex.py +0 -0
  30. {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/gate.py +0 -0
  31. {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/gitnexus.py +0 -0
  32. {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/registry.py +0 -0
  33. {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/transport.py +0 -0
  34. {codmap-0.0.5 → codmap-0.0.7}/codemap/model.py +0 -0
  35. {codmap-0.0.5 → codmap-0.0.7}/codemap/query.py +0 -0
  36. {codmap-0.0.5 → codmap-0.0.7}/codemap/scope.py +0 -0
  37. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/__init__.py +0 -0
  38. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/_scip_pb2.py +0 -0
  39. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/api_surface.py +0 -0
  40. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/apidiff.py +0 -0
  41. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/architecture.py +0 -0
  42. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/audit.py +0 -0
  43. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/check.py +0 -0
  44. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/ctags.py +0 -0
  45. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/impact.py +0 -0
  46. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/limits.py +0 -0
  47. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/livingdocs.py +0 -0
  48. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/mcp_server.py +0 -0
  49. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/mermaid.py +0 -0
  50. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/pack.py +0 -0
  51. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/rag.py +0 -0
  52. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/review.py +0 -0
  53. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/scip.py +0 -0
  54. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/semantic.py +0 -0
  55. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/server.py +0 -0
  56. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/subsystems.py +0 -0
  57. {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/vault.py +0 -0
  58. {codmap-0.0.5 → codmap-0.0.7}/codemap/store.py +0 -0
  59. {codmap-0.0.5 → codmap-0.0.7}/codemap/tomlio.py +0 -0
  60. {codmap-0.0.5 → codmap-0.0.7}/codemap/watch.py +0 -0
  61. {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/dependency_links.txt +0 -0
  62. {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/entry_points.txt +0 -0
  63. {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/requires.txt +0 -0
  64. {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/top_level.txt +0 -0
  65. {codmap-0.0.5 → codmap-0.0.7}/setup.cfg +0 -0
  66. {codmap-0.0.5 → codmap-0.0.7}/tests/test_epistemic.py +0 -0
  67. {codmap-0.0.5 → codmap-0.0.7}/tests/test_gitnexus_router.py +0 -0
  68. {codmap-0.0.5 → codmap-0.0.7}/tests/test_impact_depth.py +0 -0
  69. {codmap-0.0.5 → codmap-0.0.7}/tests/test_integrations.py +0 -0
  70. {codmap-0.0.5 → codmap-0.0.7}/tests/test_issue3_serve_freshness.py +0 -0
  71. {codmap-0.0.5 → codmap-0.0.7}/tests/test_livingdocs.py +0 -0
  72. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m0_api_surface.py +0 -0
  73. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m11_argcontract.py +0 -0
  74. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m12_dataflow.py +0 -0
  75. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m15_review.py +0 -0
  76. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m16_architecture.py +0 -0
  77. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m17_mcp.py +0 -0
  78. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m18_freshness.py +0 -0
  79. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m1_5_semantics.py +0 -0
  80. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m1_query.py +0 -0
  81. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m2_views.py +0 -0
  82. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m32_watch.py +0 -0
  83. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m3_serve.py +0 -0
  84. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m4_behavior.py +0 -0
  85. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m5_deep.py +0 -0
  86. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m6_repo_scope.py +0 -0
  87. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m7_dispatch.py +0 -0
  88. {codmap-0.0.5 → codmap-0.0.7}/tests/test_m9_family.py +0 -0
  89. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  90. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c13_grep_vs_graph.py +0 -0
  91. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c16_semantic.py +0 -0
  92. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c20_attribute_edges.py +0 -0
  93. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c21_flat_layout.py +0 -0
  94. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c22_source_visible_refs.py +0 -0
  95. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c23_hard_python.py +0 -0
  96. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c24_test_mapping.py +0 -0
  97. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c25_provenance.py +0 -0
  98. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c26_deep_union.py +0 -0
  99. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c27_config_honesty.py +0 -0
  100. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c28_limit_envelope.py +0 -0
  101. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c29_lazy_imports.py +0 -0
  102. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c2_ctags.py +0 -0
  103. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c30_local_import_calls.py +0 -0
  104. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c30f1_reexport_calls.py +0 -0
  105. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c30f2_check_scope.py +0 -0
  106. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c31_path_origin.py +0 -0
  107. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c32_report_json.py +0 -0
  108. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c33_query_signature.py +0 -0
  109. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c34_signature_kinds.py +0 -0
  110. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c35_contract_location.py +0 -0
  111. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c3_arch_contract.py +0 -0
  112. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c3_dogfood.py +0 -0
  113. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c4_complexity.py +0 -0
  114. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c5_apidiff.py +0 -0
  115. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c6_pack.py +0 -0
  116. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c7_edge_vocab.py +0 -0
  117. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c8_deadcode.py +0 -0
  118. {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c9_incremental.py +0 -0
  119. {codmap-0.0.5 → codmap-0.0.7}/tests/test_scip_export.py +0 -0
  120. {codmap-0.0.5 → codmap-0.0.7}/tests/test_scope.py +0 -0
  121. {codmap-0.0.5 → codmap-0.0.7}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.5
3
+ Version: 0.0.7
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, **734 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, **734 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 &
@@ -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
@@ -78,18 +102,7 @@ def build_structural(package_path: str | Path):
78
102
  # a future default), a graph must describe the directory that was asked for. A wrong
79
103
  # answer here is invisible downstream — it is well-formed, complete and about the
80
104
  # 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
- )
105
+ _assert_is_the_target(getattr(root, "filepath", None), pkg_dir, module_name)
93
106
 
94
107
  graph = Graph(target=module_name)
95
108
  walk = _Walk()
@@ -43,12 +43,17 @@ MATCH, OLDER, NEWER, UNKNOWN = "match", "older", "newer", "unknown"
43
43
 
44
44
  # -- tool identity (D2) -------------------------------------------------------
45
45
 
46
- @lru_cache(maxsize=1)
47
- def tool_version() -> str | None:
48
- """The installed distribution's version, or None when it is not installed.
46
+ def installed_version() -> str | None:
47
+ """What the distribution *currently on disk* says, read on every call.
49
48
 
50
- The single source: `codemap.__version__` reads it too, having drifted a release
51
- behind while it was a literal.
49
+ R1-C38-f1. `tool_version()` below is cached for a whole process, which is right for
50
+ provenance — every graph a build writes must carry one version — and wrong for the
51
+ only question that needs the value twice: has the installation changed underneath a
52
+ long-running server? The cached reader answers with what was true at import, so a
53
+ comparison between the two is a comparison of one value with itself.
54
+
55
+ Measured before trusting it: an upgrade performed while a process runs *is* visible
56
+ to a fresh `importlib.metadata.version()`, with no `invalidate_caches()` needed.
52
57
  """
53
58
  from importlib.metadata import PackageNotFoundError, version
54
59
  try:
@@ -57,6 +62,19 @@ def tool_version() -> str | None:
57
62
  return None
58
63
 
59
64
 
65
+ @lru_cache(maxsize=1)
66
+ def tool_version() -> str | None:
67
+ """The installed distribution's version, or None when it is not installed.
68
+
69
+ The single source: `codemap.__version__` reads it too, having drifted a release
70
+ behind while it was a literal.
71
+
72
+ Cached on purpose — a build stamps one version into everything it writes. Anything
73
+ asking *"is this still true?"* rather than *"what was it?"* wants `installed_version()`.
74
+ """
75
+ return installed_version()
76
+
77
+
60
78
 
61
79
  @lru_cache(maxsize=8)
62
80
  def _commit_of(root: Path) -> str | None:
@@ -97,6 +97,38 @@ def _match(q: Query, n) -> dict:
97
97
  return e
98
98
 
99
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 — which is a
113
+ property of *how the caller reads it*, not of this function. Shipped once without it
114
+ (0.0.6): `Session._tool_drift` passed the cached `tool_version()` on both sides, so the
115
+ two arguments were the same value by construction and this returned `None` forever.
116
+ See `installed_version()` and R1-C38-f1.
117
+ """
118
+ if not running or not installed or running == installed:
119
+ return None
120
+ return {
121
+ "code": "tool_restart_needed",
122
+ "severity": "warning",
123
+ "running": running,
124
+ "installed": installed,
125
+ "consequence": "Answers keep the shape of the running code; `reload` refreshes the "
126
+ "graph, never the code.",
127
+ "message": f"this server is running codemap {running} while {installed} is installed — "
128
+ "restart it to serve the installed version.",
129
+ }
130
+
131
+
100
132
  def build_query_result(q: Query, name: str) -> dict:
101
133
  """The full symbol dossier — shared by ``codemap query`` and warm serve."""
102
134
  matches = q.find(name)
@@ -267,11 +299,25 @@ class Session:
267
299
  # edges from a layout the extractor didn't understand), rather than letting every
268
300
  # downstream conclusion silently inherit the emptiness.
269
301
  from codemap.diagnostics import diagnostics
270
- diags = diagnostics(self.graph)
302
+ diags = list(diagnostics(self.graph))
303
+ # R1-C38: the graph is not the only thing that goes stale in a warm server.
304
+ drift = self._tool_drift()
305
+ if drift:
306
+ diags.append(drift)
271
307
  if diags:
272
308
  out["diagnostics"] = diags
273
309
  return out
274
310
 
311
+ @staticmethod
312
+ def _tool_drift() -> dict | None:
313
+ # The two sides must be read differently or there is nothing to compare (R1-C38-f1):
314
+ # `codemap.__version__` is fixed at import — that is the *running* code — while the
315
+ # installed distribution is re-read here, on every call. Using the cached
316
+ # `tool_version()` on the right made this compare a value with itself.
317
+ import codemap
318
+ from codemap.provenance import installed_version
319
+ return tool_drift(getattr(codemap, "__version__", None), installed_version())
320
+
275
321
  def _op_reload(self, args) -> dict:
276
322
  """Reload the on-disk artifact into the served graph, without a restart (#3).
277
323
 
@@ -297,10 +343,16 @@ class Session:
297
343
  self.query = Query(graph)
298
344
  self._served_mtime = self._current_mtime()
299
345
  after = {"nodes": len(graph.nodes), "edges": len(graph.edges)}
300
- return {"reloaded": True, "before": before, "after": after,
301
- "changed": before != after,
302
- "freshness": freshness(self.graph_path,
303
- served_mtime=self._served_mtime)}
346
+ out = {"reloaded": True, "before": before, "after": after,
347
+ "changed": before != after,
348
+ "freshness": freshness(self.graph_path,
349
+ served_mtime=self._served_mtime)}
350
+ # R1-C38: this is the op whose name promises freshness, so it is the op that owes
351
+ # the caller the half it cannot deliver — the code, which only a restart replaces.
352
+ drift = self._tool_drift()
353
+ if drift:
354
+ out["diagnostics"] = [drift]
355
+ return out
304
356
 
305
357
  def _op_query(self, args) -> dict:
306
358
  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.5
3
+ Version: 0.0.7
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, **734 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 &
@@ -104,6 +104,8 @@ tests/test_r1c33_query_signature.py
104
104
  tests/test_r1c34_signature_kinds.py
105
105
  tests/test_r1c35_contract_location.py
106
106
  tests/test_r1c36_target_resolution.py
107
+ tests/test_r1c37_rule_mutation.py
108
+ tests/test_r1c38_tool_drift.py
107
109
  tests/test_r1c3_arch_contract.py
108
110
  tests/test_r1c3_dogfood.py
109
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.5"
12
+ version = "0.0.7"
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
@@ -40,6 +40,7 @@ import sys
40
40
  import pytest
41
41
 
42
42
  from codemap.extract import extract
43
+ from codemap.extract.griffe_extractor import _assert_is_the_target
43
44
 
44
45
  TOY = {
45
46
  "__init__.py": '"""A package that merely shares a name with another one."""\n',
@@ -105,3 +106,70 @@ def test_two_copies_of_one_package_give_two_different_graphs(tmp_path):
105
106
  gb = {n.id for n in extract(str(b)).nodes.values()}
106
107
  assert "pkg.mod.only_in_a" in ga and "pkg.mod.only_in_a" not in gb
107
108
  assert "pkg.mod.only_in_b" in gb and "pkg.mod.only_in_b" not in ga
109
+
110
+
111
+ # -- the guard on shapes a single search path cannot produce end to end -------
112
+ #
113
+ # A namespace package assembled from several directories is a legitimate resolution: the
114
+ # requested one is *among* the parts. The end-to-end path cannot reach that case, because
115
+ # a build passes exactly one `search_paths` entry, so nothing here would have caught a
116
+ # guard that rejected it — which is why the decision is tested directly rather than
117
+ # assumed from the single-directory case that does run.
118
+
119
+ def test_the_guard_accepts_a_namespace_package_the_target_is_part_of(tmp_path):
120
+ a, b = tmp_path / "part_a" / "ns", tmp_path / "part_b" / "ns"
121
+ a.mkdir(parents=True)
122
+ b.mkdir(parents=True)
123
+ _assert_is_the_target([a, b], a.resolve(), "ns") # must not raise
124
+ _assert_is_the_target([b, a], a.resolve(), "ns") # order is not significance
125
+
126
+
127
+ def test_the_guard_rejects_a_namespace_package_the_target_is_not_part_of(tmp_path):
128
+ a, b, asked = tmp_path / "a" / "ns", tmp_path / "b" / "ns", tmp_path / "c" / "ns"
129
+ for d in (a, b, asked):
130
+ d.mkdir(parents=True)
131
+ with pytest.raises(ValueError) as e:
132
+ _assert_is_the_target([a, b], asked.resolve(), "ns")
133
+ assert str(asked) in str(e.value) and str(a) in str(e.value), "name both sides"
134
+
135
+
136
+ def test_a_single_file_path_is_compared_by_its_directory(tmp_path):
137
+ pkg = tmp_path / "pkg"
138
+ pkg.mkdir()
139
+ (pkg / "__init__.py").write_text("")
140
+ _assert_is_the_target(pkg / "__init__.py", pkg.resolve(), "pkg")
141
+
142
+
143
+ def test_silence_is_not_a_mismatch(tmp_path):
144
+ """griffe saying nothing about the file is not griffe naming the wrong one. Inventing
145
+ a failure from an absent answer is the same error pointing the other way."""
146
+ _assert_is_the_target(None, (tmp_path / "pkg").resolve(), "pkg")
147
+ _assert_is_the_target([], (tmp_path / "pkg").resolve(), "pkg")
148
+
149
+
150
+ def test_a_namespace_layout_does_not_merge_the_two_trees(tmp_path):
151
+ """The flat-layout case, and it fails worse than the package one.
152
+
153
+ Measured against the released 0.0.4 after the fix was already in: with a same-named
154
+ directory in the working directory, the graph came back as the **union** of both
155
+ trees — `core.registry` and `core.user` from the target *and* `core.impostor` from the
156
+ shadow. Namespace packages are additive, so griffe merged the parts it found instead
157
+ of one winning, and the result described a code base that exists nowhere.
158
+
159
+ Harder to notice than the package case, too: node counts go *up*, not sideways, and
160
+ every symbol in the graph is a real symbol from some real file.
161
+ """
162
+ target, shadow = tmp_path / "elsewhere" / "core", tmp_path / "workdir" / "core"
163
+ target.mkdir(parents=True)
164
+ shadow.mkdir(parents=True)
165
+ (target / "registry.py").write_text("def own():\n return 1\n")
166
+ (target / "user.py").write_text("import registry\n\n\ndef go():\n return registry.own()\n")
167
+ (shadow / "impostor.py").write_text("def impostor():\n return 0\n")
168
+
169
+ out = tmp_path / "g.json"
170
+ r = subprocess.run([sys.executable, "-m", "codemap.cli", "build", str(target), "-o", str(out)],
171
+ cwd=str(shadow.parent), capture_output=True, text=True)
172
+ assert r.returncode == 0, r.stderr
173
+ ids = {n["id"] for n in json.loads(out.read_text())["nodes"]}
174
+ assert {"core.registry.own", "core.user.go"} <= ids
175
+ assert not any("impostor" in i for i in ids), "the two trees were merged into one graph"
@@ -0,0 +1,178 @@
1
+ """R1-C37 — every contract rule, fed the thing it must react to, on a real tree.
2
+
3
+ The method is the lab's, not ours. Sent a `no_lazy_cycles` gate that answered green on
4
+ their repository, they did not believe the green: they put **one** lazy import back and
5
+ watched the gate go red. That is the only evidence a gate is doing anything, and after
6
+ running it on our two trees the obvious question was which of the six rules had ever been
7
+ given the same treatment.
8
+
9
+ Answer: as end-to-end paths, none. Each rule does have a violation test
10
+ (`test_r1c3_arch_contract.py`), and each is built on a hand-assembled three-node `Graph`
11
+ whose layers are spelled into the node ids and whose `root` is set by hand. That proves
12
+ the rule's arithmetic. It cannot show that a rule finds a real violation in a real graph,
13
+ because nothing between source and rule participates: no extraction, no layer inference
14
+ from a package tree, no re-exports, no lazy edges, no thousands of legitimate imports for
15
+ the offending one to hide among. The dogfood test (`test_r1c3_dogfood.py`) runs the real
16
+ tree — and asserts green, which is the shape of evidence the lab correctly refused.
17
+
18
+ So: one copy of codemap's own package, and for each rule, green → change exactly one line
19
+ → red naming the edge that was added → restore → the file is byte-identical again. The
20
+ contract in each case holds **only the rule under test**, so nothing else can fire and be
21
+ mistaken for it. Mutation 1 also creates a cycle, for instance; under a layers-only
22
+ contract that cannot be confused with a layering violation.
23
+
24
+ Cost is about 20 s: seven extractions of a 90-file package. That is the price of testing
25
+ the path that runs in production instead of the arithmetic underneath it.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import shutil
31
+
32
+ import pytest
33
+
34
+ from codemap.arch import check_contract, parse_contract
35
+ from codemap.extract import extract
36
+ from codemap.query import Query
37
+
38
+ # Every top-level component of codemap that exists today. `exhaustive` is about a *new*
39
+ # one appearing, so the baseline has to name all of them or it is red before we start.
40
+ # A topological order of codemap's *real* cross-layer imports, not the shipped contract's
41
+ # ten-layer view: `codemap.toml` names the layers a reader should think in, and leaves the
42
+ # six leaf modules undeclared (harmless — an undeclared layer is inert unless `exhaustive`).
43
+ # Here every component must be named, or the baseline is red before a mutation is applied
44
+ # and the mutation proves nothing. The baseline assertion in each test guards this list:
45
+ # if it goes stale, the failure says so rather than passing quietly.
46
+ REAL_LAYERS = ["cli", "incremental", "extract", "serve", "apidiff", "arch", "diagnostics",
47
+ "freshness", "integrations", "provenance", "query", "store", "model",
48
+ "tomlio", "watch", "scope"]
49
+
50
+ SRC = __import__("pathlib").Path(__file__).resolve().parent.parent / "codemap"
51
+
52
+
53
+ @pytest.fixture(scope="module")
54
+ def tree(tmp_path_factory):
55
+ """One copy of the real package. Mutations are applied and reverted in place."""
56
+ dst = tmp_path_factory.mktemp("mutation") / "codemap"
57
+ shutil.copytree(SRC, dst)
58
+ return dst
59
+
60
+
61
+ @pytest.fixture(scope="module")
62
+ def baseline(tree):
63
+ """The unmutated graph — every `green →` half of the six checks below."""
64
+ return Query(extract(str(tree)))
65
+
66
+
67
+ def _check(q, rules: dict):
68
+ return check_contract(q, parse_contract(rules))
69
+
70
+
71
+ def _mutate(tree, rel: str, line: str) -> tuple[str, str]:
72
+ """Append one import line to a real module. Returns (path, original text)."""
73
+ path = tree / rel
74
+ original = path.read_text()
75
+ path.write_text(original + f"\n\n{line}\n")
76
+ return str(path), original
77
+
78
+
79
+ def _restore(path: str, original: str) -> None:
80
+ __import__("pathlib").Path(path).write_text(original)
81
+
82
+
83
+ def _run(tree, rel, line, rules):
84
+ """green → mutate → measure → restore → byte-identical. Returns the violations."""
85
+ path, original = _mutate(tree, rel, line)
86
+ try:
87
+ return check_contract(Query(extract(str(tree))), parse_contract(rules))
88
+ finally:
89
+ _restore(path, original)
90
+ assert (tree / rel).read_text() == original
91
+
92
+
93
+ # -- one rule per mutation, each with a contract holding only that rule ------
94
+
95
+ def test_layers_catches_an_import_up_the_stack(tree, baseline):
96
+ rules = {"layers": REAL_LAYERS}
97
+ assert _check(baseline, rules) == [], "baseline must be green or the mutation proves nothing"
98
+ v = _run(tree, "model.py", "from codemap.cli import main # noqa: F401", rules)
99
+ assert [x.rule for x in v] == ["layered"]
100
+ assert ("codemap.model", "codemap.cli") in v[0].edges
101
+
102
+
103
+ def test_independent_catches_a_new_edge_between_two_declared_peers(tree, baseline):
104
+ rules = {"independent": [["extract", "serve"]]}
105
+ assert _check(baseline, rules) == [], "extract and serve are independent today"
106
+ v = _run(tree, "extract/roots.py",
107
+ "from codemap.serve.check import render_check # noqa: F401", rules)
108
+ assert [x.rule for x in v] == ["independent"]
109
+ assert ("codemap.extract.roots", "codemap.serve.check") in v[0].edges
110
+
111
+
112
+ def test_forbidden_catches_the_banned_direction_only(tree, baseline):
113
+ rules = {"forbidden": [{"from": "model", "to": "query"}]}
114
+ assert _check(baseline, rules) == []
115
+ v = _run(tree, "model.py", "from codemap.query import Query # noqa: F401", rules)
116
+ assert [x.rule for x in v] == ["forbidden"]
117
+ assert ("codemap.model", "codemap.query") in v[0].edges
118
+ # The same edge under the opposite ban is not a violation.
119
+ assert _check(baseline, {"forbidden": [{"from": "query", "to": "model"}]})
120
+
121
+
122
+ def test_no_cycles_catches_a_cycle_the_import_actually_closes(tree, baseline):
123
+ rules = {"no_cycles": True}
124
+ assert _check(baseline, rules) == [], "codemap's eager import graph is acyclic today"
125
+ v = _run(tree, "model.py", "from codemap.store import save # noqa: F401", rules)
126
+ assert [x.rule for x in v] == ["no_cycles"]
127
+ # `modules` on a cycle violation holds *rendered paths*, not ids — "a → b → a".
128
+ assert any("codemap.model" in c and "codemap.store" in c for c in v[0].modules), v[0].modules
129
+
130
+
131
+ def test_no_lazy_cycles_catches_a_cycle_only_a_function_local_import_closes(tree, baseline):
132
+ rules = {"no_lazy_cycles": True}
133
+ assert _check(baseline, rules) == []
134
+ line = ("def _mutation_probe():\n"
135
+ " from codemap.serve.vault import build_vault\n"
136
+ " return build_vault")
137
+ v = _run(tree, "query.py", line, rules)
138
+ assert [x.rule for x in v] == ["no_lazy_cycles"]
139
+ assert any("codemap.query" in c and "codemap.serve.vault" in c for c in v[0].modules), v[0].modules
140
+
141
+
142
+ def test_no_cycles_stays_silent_on_the_lazy_one(tree):
143
+ """The pair that makes the two rules different rules, on the real tree."""
144
+ line = ("def _mutation_probe():\n"
145
+ " from codemap.serve.vault import build_vault\n"
146
+ " return build_vault")
147
+ path, original = _mutate(tree, "query.py", line)
148
+ try:
149
+ q = Query(extract(str(tree)))
150
+ assert check_contract(q, parse_contract({"no_cycles": True})) == []
151
+ assert check_contract(q, parse_contract({"no_lazy_cycles": True}))
152
+ finally:
153
+ _restore(path, original)
154
+
155
+
156
+ def test_exhaustive_catches_a_new_undeclared_component(tree, baseline):
157
+ rules = {"layers": REAL_LAYERS, "exhaustive": True}
158
+ assert _check(baseline, rules) == [], "REAL_LAYERS is stale — a component was added"
159
+ newpkg = tree / "newlayer"
160
+ newpkg.mkdir()
161
+ (newpkg / "__init__.py").write_text('"""A component nobody declared."""\n')
162
+ try:
163
+ v = check_contract(Query(extract(str(tree))), parse_contract(rules))
164
+ assert [x.rule for x in v] == ["exhaustive"]
165
+ assert "newlayer" in v[0].modules
166
+ finally:
167
+ shutil.rmtree(newpkg)
168
+
169
+
170
+ def test_the_tree_is_unchanged_after_every_mutation(tree, baseline):
171
+ """The other half of a mutation test, once: the red went away with the change that
172
+ caused it, so the reds above were the mutations and not the harness."""
173
+ after = Query(extract(str(tree)))
174
+ assert set(after.graph.nodes) == set(baseline.graph.nodes)
175
+ rules = {"layers": REAL_LAYERS, "no_cycles": True, "no_lazy_cycles": True,
176
+ "exhaustive": True, "independent": [["extract", "serve"]],
177
+ "forbidden": [{"from": "model", "to": "query"}]}
178
+ assert check_contract(after, parse_contract(rules)) == []
@@ -0,0 +1,124 @@
1
+ """R1-C38 — a warm server holds a graph *and* code, and only one of them reloads.
2
+
3
+ Measured, not imagined. While closing an unrelated debt — "the MCP path for the new
4
+ fields was exercised through `Session.handle`, not through a live agent" — the live check
5
+ was run: the bquant graph was rebuilt with 0.0.5 and `reload` picked it up, 4656 → 4743
6
+ nodes, the schema-mismatch diagnostic gone. The dossier came back **without `signature`**,
7
+ which the rebuilt artifact plainly contained.
8
+
9
+ Nothing was broken. The server process had been started before R1-C33 landed, so it was
10
+ running the old `build_query_result` over a new graph, and no part of the answer said so.
11
+ `reload` refreshes the artifact; nothing refreshes the code.
12
+
13
+ `stats` already separates the graph's schema from the running tool's (R1-C25). This is the
14
+ same separation one level up, and it is only observable because the version comes from the
15
+ installed metadata, read at call time, while the code was loaded at import: when those two
16
+ disagree, a restart is pending. `reload` carries the diagnostic too — the op whose name
17
+ promises freshness owes the caller the half it cannot deliver.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from codemap.serve.session import tool_drift
23
+
24
+
25
+ def test_agreement_is_silent():
26
+ assert tool_drift("0.0.5", "0.0.5") is None
27
+
28
+
29
+ def test_a_process_older_than_what_is_installed_says_restart():
30
+ d = tool_drift("0.0.4", "0.0.5")
31
+ assert d["code"] == "tool_restart_needed" and d["severity"] == "warning"
32
+ assert d["running"] == "0.0.4" and d["installed"] == "0.0.5"
33
+ assert "restart" in d["message"]
34
+
35
+
36
+ def test_it_names_reload_as_the_thing_that_will_not_help():
37
+ """The whole point: the user's next move would otherwise be `reload`, again."""
38
+ assert "reload" in tool_drift("0.0.4", "0.0.5")["consequence"]
39
+
40
+
41
+ def test_the_other_direction_is_reported_too():
42
+ """A process newer than the installed distribution is equally a mismatch — saying
43
+ nothing about it would be guessing which direction is the interesting one."""
44
+ d = tool_drift("0.0.6", "0.0.5")
45
+ assert d is not None and d["running"] == "0.0.6" and d["installed"] == "0.0.5"
46
+
47
+
48
+ def test_an_unknown_version_is_not_a_drift():
49
+ """Not installed, or a version we could not read, is silence — not a warning invented
50
+ out of an absent answer."""
51
+ assert tool_drift(None, "0.0.5") is None
52
+ assert tool_drift("0.0.5", None) is None
53
+ assert tool_drift(None, None) is None
54
+
55
+
56
+ def _shadow_installed_version(tmp_path, monkeypatch, version: str) -> None:
57
+ """Make the *installed* distribution look like `version`, the way an upgrade would.
58
+
59
+ A `dist-info` directory earlier on `sys.path` wins, so this changes what
60
+ `importlib.metadata` answers without touching the imported module — exactly the
61
+ asymmetry a real upgrade under a running server creates.
62
+ """
63
+ d = tmp_path / "site"
64
+ (d / f"codmap-{version}.dist-info").mkdir(parents=True)
65
+ (d / f"codmap-{version}.dist-info" / "METADATA").write_text(
66
+ f"Metadata-Version: 2.1\nName: codmap\nVersion: {version}\n")
67
+ monkeypatch.syspath_prepend(str(d))
68
+
69
+
70
+ def test_the_installed_side_is_read_fresh_or_there_is_nothing_to_compare(tmp_path, monkeypatch):
71
+ """R1-C38-f1 — the defect 0.0.6 shipped, and the reason no test caught it.
72
+
73
+ Every other test here hands `tool_drift` two versions, or forces a difference by
74
+ patching `codemap.__version__`. Both prove the arithmetic. Neither asks the question
75
+ the feature exists for: *can the condition arise?* It could not — `_tool_drift` read
76
+ the installed side through `tool_version()`, which is `lru_cache`d and first called at
77
+ import by `codemap/__init__.py`, so both sides were the same cached value forever.
78
+
79
+ Here nothing about the process is patched. Only the installation changes, as an
80
+ upgrade changes it, and the running server must notice.
81
+ """
82
+ from codemap.serve.session import Session
83
+
84
+ assert Session._tool_drift() is None # agreement before the "upgrade"
85
+ _shadow_installed_version(tmp_path, monkeypatch, "9.9.9")
86
+
87
+ d = Session._tool_drift()
88
+ assert d is not None, "the installed version is being read from a process-lifetime cache"
89
+ assert d["code"] == "tool_restart_needed" and d["installed"] == "9.9.9"
90
+
91
+
92
+ def test_the_build_side_stays_cached(tmp_path, monkeypatch):
93
+ """The fix must not turn provenance into a per-call filesystem read: a build stamps
94
+ one version into everything it writes, so `tool_version()` keeps its cache. The two
95
+ readers exist because they answer different questions — "what was it?" and "is it
96
+ still?" — and this is the assertion that keeps them apart."""
97
+ from codemap.provenance import installed_version, tool_version
98
+
99
+ before = tool_version()
100
+ _shadow_installed_version(tmp_path, monkeypatch, "9.9.9")
101
+ assert tool_version() == before
102
+ assert installed_version() == "9.9.9"
103
+
104
+
105
+ def test_stats_and_reload_carry_it_when_it_applies(tmp_path, monkeypatch):
106
+ import codemap
107
+ from codemap.extract import extract
108
+ from codemap.serve.session import Session
109
+ from codemap.store import save
110
+
111
+ pkg = tmp_path / "pkg"
112
+ pkg.mkdir()
113
+ (pkg / "__init__.py").write_text("")
114
+ (pkg / "m.py").write_text("def f():\n return 1\n")
115
+ out = tmp_path / "g.json"
116
+ graph = extract(str(pkg))
117
+ save(graph, str(out))
118
+
119
+ s = Session(graph, graph_path=str(out))
120
+ monkeypatch.setattr(codemap, "__version__", "0.0.0-old", raising=False)
121
+ codes = {d["code"] for d in s.handle({"op": "stats", "args": {}})["result"].get("diagnostics", [])}
122
+ assert "tool_restart_needed" in codes
123
+ reload_out = s.handle({"op": "reload", "args": {}})["result"]
124
+ assert any(d["code"] == "tool_restart_needed" for d in reload_out.get("diagnostics", []))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes