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.
- {codmap-0.0.4 → codmap-0.0.6}/PKG-INFO +2 -2
- {codmap-0.0.4 → codmap-0.0.6}/README.md +1 -1
- {codmap-0.0.4 → codmap-0.0.6}/codemap/apidiff.py +15 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/arch.py +13 -4
- {codmap-0.0.4 → codmap-0.0.6}/codemap/cli.py +8 -1
- {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/griffe_extractor.py +84 -9
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/check.py +12 -2
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/mcp_server.py +8 -2
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/session.py +80 -7
- {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/PKG-INFO +2 -2
- {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/SOURCES.txt +6 -0
- {codmap-0.0.4 → codmap-0.0.6}/pyproject.toml +1 -1
- codmap-0.0.6/tests/test_r1c33_query_signature.py +189 -0
- codmap-0.0.6/tests/test_r1c34_signature_kinds.py +150 -0
- codmap-0.0.6/tests/test_r1c35_contract_location.py +101 -0
- codmap-0.0.6/tests/test_r1c36_target_resolution.py +175 -0
- codmap-0.0.6/tests/test_r1c37_rule_mutation.py +178 -0
- codmap-0.0.6/tests/test_r1c38_tool_drift.py +75 -0
- {codmap-0.0.4 → codmap-0.0.6}/LICENSE +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/__init__.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/diagnostics.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/extract/roots.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/freshness.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/incremental.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/base.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/model.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/provenance.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/query.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/scope.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/audit.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/impact.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/limits.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/pack.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/rag.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/review.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/scip.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/server.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/serve/vault.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/store.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/tomlio.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codemap/watch.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/setup.cfg +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_epistemic.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_integrations.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m15_review.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m1_query.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m2_views.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_m9_family.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_scip_export.py +0 -0
- {codmap-0.0.4 → codmap-0.0.6}/tests/test_scope.py +0 -0
- {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.
|
|
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
|
[](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, **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
|
[](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, **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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
523
|
-
for p in
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
if
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
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
|
|
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)
|
|
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": [
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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.
|
|
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
|
[](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, **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.
|
|
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")
|