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.
- {codmap-0.0.5 → codmap-0.0.7}/PKG-INFO +2 -2
- {codmap-0.0.5 → codmap-0.0.7}/README.md +1 -1
- {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/griffe_extractor.py +25 -12
- {codmap-0.0.5 → codmap-0.0.7}/codemap/provenance.py +23 -5
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/session.py +57 -5
- {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/PKG-INFO +2 -2
- {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/SOURCES.txt +2 -0
- {codmap-0.0.5 → codmap-0.0.7}/pyproject.toml +1 -1
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c36_target_resolution.py +68 -0
- codmap-0.0.7/tests/test_r1c37_rule_mutation.py +178 -0
- codmap-0.0.7/tests/test_r1c38_tool_drift.py +124 -0
- {codmap-0.0.5 → codmap-0.0.7}/LICENSE +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/__init__.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/apidiff.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/arch.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/cli.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/diagnostics.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/extract/roots.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/freshness.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/incremental.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/base.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/model.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/query.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/scope.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/audit.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/check.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/impact.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/limits.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/pack.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/rag.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/review.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/scip.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/server.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/serve/vault.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/store.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/tomlio.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codemap/watch.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/setup.cfg +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_epistemic.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_integrations.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m15_review.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m1_query.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m2_views.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_m9_family.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_scip_export.py +0 -0
- {codmap-0.0.5 → codmap-0.0.7}/tests/test_scope.py +0 -0
- {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.
|
|
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
|
[](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
|
|
46
46
|
|
|
47
|
-
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **
|
|
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
|
[](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
|
|
10
10
|
|
|
11
|
-
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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.
|
|
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
|
[](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
|
|
46
46
|
|
|
47
|
-
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **
|
|
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.
|
|
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
|
|
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
|
|
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
|