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