codmap 0.0.23__tar.gz → 0.0.24__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.23/codmap.egg-info → codmap-0.0.24}/PKG-INFO +2 -2
- {codmap-0.0.23 → codmap-0.0.24}/README.md +1 -1
- {codmap-0.0.23 → codmap-0.0.24}/codemap/cli.py +33 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/behavior.py +130 -22
- {codmap-0.0.23 → codmap-0.0.24}/codemap/model.py +5 -1
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/audit.py +44 -10
- {codmap-0.0.23 → codmap-0.0.24/codmap.egg-info}/PKG-INFO +2 -2
- {codmap-0.0.23 → codmap-0.0.24}/codmap.egg-info/SOURCES.txt +2 -0
- {codmap-0.0.23 → codmap-0.0.24}/pyproject.toml +1 -1
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c63_answer_format.py +2 -1
- codmap-0.0.24/tests/test_r1c73_cli_version.py +72 -0
- codmap-0.0.24/tests/test_r1c74_call_site_conservation.py +169 -0
- {codmap-0.0.23 → codmap-0.0.24}/LICENSE +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/__init__.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/apidiff.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/arch.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/diagnostics.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/griffe_extractor.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/probe.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/roots.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/union.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/freshness.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/incremental.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/base.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/provenance.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/query.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/scope.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/check.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/currency.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/impact.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/limits.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/pack.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/rag.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/review.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/scip.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/server.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/session.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/vault.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/store.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/tomlio.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codemap/watch.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/setup.cfg +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_epistemic.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_integrations.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m15_review.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m1_query.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m2_views.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_m9_family.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_pack_honesty.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c48_type_checking_imports.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c49_type_only_cycles.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c53_narrowing_declared.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c54_deterministic_rendering.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c55_override_grade.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c56_stub_files.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c57_facade_package.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c58_cycle_tangles.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c59_completeness_drop.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c60_nested_definitions.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c62_phantom_rules.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c64_dependency_probe.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c66_pack_direction.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c68_dotted_imports.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c69_contract_validation.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c70_check_graph_currency.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c71_attribute_chain_calls.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c72_flat_inference_in_packages.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_scip_export.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/tests/test_scope.py +0 -0
- {codmap-0.0.23 → codmap-0.0.24}/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.24
|
|
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, **1128 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, **1128 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 &
|
|
@@ -982,8 +982,41 @@ def _add_source(p) -> None:
|
|
|
982
982
|
help="Deep call resolution via jedi (richer, ~1 min; default fast).")
|
|
983
983
|
|
|
984
984
|
|
|
985
|
+
def version_line() -> str:
|
|
986
|
+
"""``codemap 0.0.24 @abc1234 · answer format 4 · schema 0.13`` (R1-C73).
|
|
987
|
+
|
|
988
|
+
Built from ``provenance.tool_identity()`` — the identity every graph records as
|
|
989
|
+
``provenance.tool`` — so the CLI and the artifact cannot disagree. Commit and dirtiness
|
|
990
|
+
appear only when known, as there; a missing distribution is said, never invented.
|
|
991
|
+
"""
|
|
992
|
+
from codemap.model import ANSWER_FORMAT, SCHEMA_VERSION
|
|
993
|
+
from codemap.provenance import tool_identity
|
|
994
|
+
t = tool_identity()
|
|
995
|
+
head = f"codemap {t['version']}" if t.get("version") else \
|
|
996
|
+
"codemap version unknown (not installed as a distribution)"
|
|
997
|
+
if t.get("commit"):
|
|
998
|
+
head += f" @{t['commit']}"
|
|
999
|
+
if t.get("dirty"):
|
|
1000
|
+
head += " (dirty)"
|
|
1001
|
+
return f"{head} · answer format {ANSWER_FORMAT} · schema {SCHEMA_VERSION}"
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
class _VersionAction(argparse.Action):
|
|
1005
|
+
"""``--version``, computed only when asked: ``tool_identity()`` calls git (design D1)."""
|
|
1006
|
+
|
|
1007
|
+
def __init__(self, option_strings, dest=argparse.SUPPRESS, default=argparse.SUPPRESS,
|
|
1008
|
+
help="Print the installed version, the answer format and the graph schema, then exit."):
|
|
1009
|
+
super().__init__(option_strings=option_strings, dest=dest, default=default, nargs=0,
|
|
1010
|
+
help=help)
|
|
1011
|
+
|
|
1012
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
1013
|
+
print(version_line())
|
|
1014
|
+
parser.exit(0)
|
|
1015
|
+
|
|
1016
|
+
|
|
985
1017
|
def build_parser() -> argparse.ArgumentParser:
|
|
986
1018
|
p = argparse.ArgumentParser(prog="codemap", description="Static code-graph builder.")
|
|
1019
|
+
p.add_argument("--version", action=_VersionAction)
|
|
987
1020
|
sub = p.add_subparsers(dest="command", required=True)
|
|
988
1021
|
|
|
989
1022
|
b = sub.add_parser("build", help="Build the canonical graph from a package path.")
|
|
@@ -83,11 +83,18 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
|
|
|
83
83
|
# R1-C46: the surviving definition says which earlier bodies it replaced. Stamped
|
|
84
84
|
# here because this pass holds the AST; the walkers below skip those bodies.
|
|
85
85
|
_stamp_shadows(graph, tree, modpath)
|
|
86
|
+
walked_here: set[str] = set() # R1-C74: nodes that already took a body in this module
|
|
87
|
+
shadow_skip = _shadow_map(tree)[0]
|
|
86
88
|
for fnode, class_stack, scope in _named_functions_scoped(tree):
|
|
87
89
|
node_id = _node_id(modpath, class_stack, fnode.name)
|
|
88
90
|
local = local_imports.get(id(fnode))
|
|
89
91
|
fn_imports = {**imports, **local} if local else imports
|
|
90
|
-
|
|
92
|
+
# R1-C74: a def nested in a function is never a definition node, whatever its
|
|
93
|
+
# name. `_node_id` sees only the class chain, so `def getfixturedefs` inside a method
|
|
94
|
+
# of `FixtureManager` used to *become* the method `FixtureManager.getfixturedefs` —
|
|
95
|
+
# its calls and edges attributed to a namesake (found by the conservation check).
|
|
96
|
+
nested_in_function = len(scope) > len(class_stack)
|
|
97
|
+
if node_id not in graph.nodes or nested_in_function:
|
|
91
98
|
# R1-C22 D3: a closure / dynamically-built class body is not a definition
|
|
92
99
|
# node, but the calls inside it are real. Attribute them to the innermost
|
|
93
100
|
# definition that *does* exist instead of discarding them.
|
|
@@ -103,7 +110,9 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
|
|
|
103
110
|
return _resolve(call, modpath, _cp, _im, _mm, _me, reexports, nodes)
|
|
104
111
|
resolve = (_deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules)
|
|
105
112
|
if script else fast)
|
|
106
|
-
_process_function(graph, node_id, fnode, resolve
|
|
113
|
+
_process_function(graph, node_id, fnode, resolve,
|
|
114
|
+
accumulate=node_id in walked_here)
|
|
115
|
+
walked_here.add(node_id)
|
|
107
116
|
# R1-C22 D1: functions/classes named as *values* here (dispatch tables,
|
|
108
117
|
# `default=` callbacks) — a use the call layer cannot see.
|
|
109
118
|
_emit_name_references(graph, node_id, fnode, modpath, fn_imports, modmembers,
|
|
@@ -115,7 +124,11 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
|
|
|
115
124
|
mod_resolve = (_deep_then_fast(graph, script, target_pkg, mod_fast, modpath, known_modules)
|
|
116
125
|
if script else mod_fast)
|
|
117
126
|
_process_module_level(graph, modpath, tree, mod_resolve, imports, modmembers,
|
|
118
|
-
reexports)
|
|
127
|
+
reexports, shadow_skip=shadow_skip)
|
|
128
|
+
# R1-C74: class bodies run at import time and were walked by nobody.
|
|
129
|
+
_process_class_bodies(graph, modpath, tree, mod_resolve, imports, modmembers,
|
|
130
|
+
script, target_pkg, nested, known_modules, reexports,
|
|
131
|
+
skip=shadow_skip)
|
|
119
132
|
_emit_nested_calls(graph, nested)
|
|
120
133
|
|
|
121
134
|
|
|
@@ -525,30 +538,49 @@ def _deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules):
|
|
|
525
538
|
return resolve
|
|
526
539
|
|
|
527
540
|
|
|
528
|
-
def
|
|
529
|
-
|
|
541
|
+
def _new_counts() -> dict:
|
|
542
|
+
return {"out": 0, "resolved": 0, "external": 0, "unresolved": 0, "dynamic": 0}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _tally(counts: dict, graph, target, resolution) -> bool:
|
|
546
|
+
"""Count one call site by how it resolved; True when it becomes an internal edge.
|
|
547
|
+
|
|
548
|
+
One rule for every scope (R1-C74): a function body, a nested function, module-level
|
|
549
|
+
code and a class body are classified the same way, so their counters can be summed
|
|
550
|
+
and checked against the source.
|
|
551
|
+
"""
|
|
552
|
+
counts["out"] += 1
|
|
553
|
+
if resolution in _EDGE_RESOLUTIONS and target and target in graph.nodes:
|
|
554
|
+
counts["resolved"] += 1
|
|
555
|
+
return True
|
|
556
|
+
if resolution == "external":
|
|
557
|
+
counts["external"] += 1
|
|
558
|
+
elif resolution == "dynamic":
|
|
559
|
+
counts["dynamic"] += 1
|
|
560
|
+
else:
|
|
561
|
+
counts["unresolved"] += 1
|
|
562
|
+
return False
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _process_function(graph, node_id, fnode, resolve, accumulate: bool = False) -> None:
|
|
566
|
+
"""``accumulate``: this node already took a body in this pass (R1-C74). A name defined in
|
|
567
|
+
both branches of an ``if``/``try``, or a legitimate rebinding (a property's setter), keeps
|
|
568
|
+
*both* bodies walked (R1-C46) — their counts add up instead of the last overwriting."""
|
|
569
|
+
counts = _new_counts()
|
|
530
570
|
# F7: edges are deduped caller->callee, so aggregate the per-call-site argument
|
|
531
571
|
# contract before emitting — a refactorer needs "how is it called", and the
|
|
532
572
|
# collapse itself (2 sites -> 1 edge) must stay visible via `callsites`.
|
|
533
573
|
by_target: dict[str, dict] = {}
|
|
534
574
|
for call in _own_calls(fnode):
|
|
535
|
-
counts["out"] += 1
|
|
536
575
|
target, resolution = resolve(call)
|
|
537
576
|
# Soundness (R1-C13-f1/f2): an internal call edge must point at a real
|
|
538
577
|
# graph node. A resolver can name a non-node — a local variable jedi typed
|
|
539
578
|
# to its own scope-path, or a nested/closure function that is not a
|
|
540
579
|
# definition node — so downgrade any such target to unresolved rather than
|
|
541
580
|
# emit an edge to nothing (which would poison callers/callees/impact).
|
|
542
|
-
if
|
|
543
|
-
counts["resolved"] += 1
|
|
581
|
+
if _tally(counts, graph, target, resolution):
|
|
544
582
|
agg = by_target.setdefault(target, {"resolution": resolution, "shapes": []})
|
|
545
583
|
agg["shapes"].append(_arg_shape(call))
|
|
546
|
-
elif resolution == "external":
|
|
547
|
-
counts["external"] += 1
|
|
548
|
-
elif resolution == "dynamic":
|
|
549
|
-
counts["dynamic"] += 1
|
|
550
|
-
else:
|
|
551
|
-
counts["unresolved"] += 1
|
|
552
584
|
|
|
553
585
|
for target in sorted(by_target):
|
|
554
586
|
agg = by_target[target]
|
|
@@ -556,7 +588,11 @@ def _process_function(graph, node_id, fnode, resolve) -> None:
|
|
|
556
588
|
graph.add_edge(Edge("calls", node_id, target, extras=extras))
|
|
557
589
|
|
|
558
590
|
node = graph.nodes[node_id]
|
|
559
|
-
|
|
591
|
+
if accumulate and "calls" in node.extras:
|
|
592
|
+
for k, v in counts.items():
|
|
593
|
+
node.extras["calls"][k] = node.extras["calls"].get(k, 0) + v
|
|
594
|
+
else:
|
|
595
|
+
node.extras["calls"] = counts
|
|
560
596
|
node.extras["control"] = _control(fnode)
|
|
561
597
|
node.extras["complexity"] = _complexity(fnode)
|
|
562
598
|
|
|
@@ -941,14 +977,30 @@ def _emit_name_references(graph, src_id, scope, modpath, imports, modmembers,
|
|
|
941
977
|
|
|
942
978
|
|
|
943
979
|
def _process_module_level(graph, modpath, tree, resolve, imports, modmembers,
|
|
944
|
-
reexports=None) -> None:
|
|
945
|
-
"""Calls and value-references in module-level code, sourced from the module (D1/D2).
|
|
980
|
+
reexports=None, shadow_skip=None) -> None:
|
|
981
|
+
"""Calls and value-references in module-level code, sourced from the module (D1/D2).
|
|
982
|
+
|
|
983
|
+
R1-C74: the module node also carries ``extras.calls`` — its sites used to make edges
|
|
984
|
+
and appear in no resolution figure.
|
|
985
|
+
"""
|
|
946
986
|
by_target: dict[str, dict] = {}
|
|
987
|
+
counts = _new_counts()
|
|
947
988
|
for call in (n for n in _module_level_nodes(tree) if isinstance(n, ast.Call)):
|
|
948
989
|
target, resolution = resolve(call)
|
|
949
|
-
if
|
|
990
|
+
if _tally(counts, graph, target, resolution):
|
|
950
991
|
agg = by_target.setdefault(target, {"resolution": resolution, "shapes": []})
|
|
951
992
|
agg["shapes"].append(_arg_shape(call))
|
|
993
|
+
if modpath in graph.nodes:
|
|
994
|
+
graph.nodes[modpath].extras["calls"] = counts
|
|
995
|
+
# R1-C74 D1: a replaced body is not walked (R1-C46) — said, not absorbed.
|
|
996
|
+
skipped = _shadow_map(tree)[0] if shadow_skip is None else shadow_skip
|
|
997
|
+
if skipped:
|
|
998
|
+
seen: set[int] = set()
|
|
999
|
+
for node in ast.walk(tree):
|
|
1000
|
+
if id(node) in skipped:
|
|
1001
|
+
seen.update(id(c) for c in ast.walk(node) if isinstance(c, ast.Call))
|
|
1002
|
+
if seen:
|
|
1003
|
+
graph.nodes[modpath].extras["calls_shadowed"] = len(seen)
|
|
952
1004
|
for target in sorted(by_target):
|
|
953
1005
|
agg = by_target[target]
|
|
954
1006
|
graph.add_edge(Edge("calls", modpath, target,
|
|
@@ -958,6 +1010,57 @@ def _process_module_level(graph, modpath, tree, resolve, imports, modmembers,
|
|
|
958
1010
|
reexports=reexports)
|
|
959
1011
|
|
|
960
1012
|
|
|
1013
|
+
def _process_class_bodies(graph, modpath, tree, resolve, imports, modmembers, script,
|
|
1014
|
+
target_pkg, nested, known_modules=frozenset(), reexports=None,
|
|
1015
|
+
skip=None) -> None:
|
|
1016
|
+
"""Calls in class bodies outside their methods — bases, keywords, decorators, statements.
|
|
1017
|
+
|
|
1018
|
+
R1-C74 (design ``call_site_conservation.md`` D1). A class body runs at import time, so a
|
|
1019
|
+
class that is a graph node is the source of its own ``calls`` edges and carries
|
|
1020
|
+
``extras.calls``, resolved like module-level code. A class nested in a function runs
|
|
1021
|
+
when the function does: its sites go to the owner as nested, like a closure's. Shadowed
|
|
1022
|
+
definitions are skipped, as everywhere (R1-C46).
|
|
1023
|
+
"""
|
|
1024
|
+
skip = _shadow_map(tree)[0] if skip is None else skip
|
|
1025
|
+
|
|
1026
|
+
def visit(node, scope, in_function):
|
|
1027
|
+
for child in ast.iter_child_nodes(node):
|
|
1028
|
+
if id(child) in skip:
|
|
1029
|
+
continue
|
|
1030
|
+
if isinstance(child, ast.ClassDef):
|
|
1031
|
+
cscope = scope + [child.name]
|
|
1032
|
+
calls = [n for n in _own_nodes(child) if isinstance(n, ast.Call)]
|
|
1033
|
+
cid = ".".join([modpath, *cscope])
|
|
1034
|
+
if not in_function and cid in graph.nodes:
|
|
1035
|
+
counts = _new_counts()
|
|
1036
|
+
by_target: dict[str, dict] = {}
|
|
1037
|
+
for call in calls:
|
|
1038
|
+
target, resolution = resolve(call)
|
|
1039
|
+
if _tally(counts, graph, target, resolution) and target != cid:
|
|
1040
|
+
agg = by_target.setdefault(target, {"resolution": resolution,
|
|
1041
|
+
"shapes": []})
|
|
1042
|
+
agg["shapes"].append(_arg_shape(call))
|
|
1043
|
+
graph.nodes[cid].extras["calls"] = counts
|
|
1044
|
+
for target in sorted(by_target):
|
|
1045
|
+
agg = by_target[target]
|
|
1046
|
+
graph.add_edge(Edge("calls", cid, target,
|
|
1047
|
+
extras={"resolution": agg["resolution"],
|
|
1048
|
+
**_arg_contract(agg["shapes"])}))
|
|
1049
|
+
elif calls:
|
|
1050
|
+
owner = _nearest_owner(graph, modpath, scope)
|
|
1051
|
+
if owner is not None:
|
|
1052
|
+
_collect_nested_calls(graph, owner, child, modpath, imports, modmembers,
|
|
1053
|
+
script, target_pkg, nested, known_modules,
|
|
1054
|
+
reexports, calls=calls)
|
|
1055
|
+
visit(child, cscope, in_function)
|
|
1056
|
+
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1057
|
+
visit(child, scope + [child.name], True)
|
|
1058
|
+
else:
|
|
1059
|
+
visit(child, scope, in_function)
|
|
1060
|
+
|
|
1061
|
+
visit(tree, [], False)
|
|
1062
|
+
|
|
1063
|
+
|
|
961
1064
|
def _named_functions_scoped(tree):
|
|
962
1065
|
"""Like :func:`_named_functions`, plus the **full** enclosing scope names.
|
|
963
1066
|
|
|
@@ -1001,17 +1104,22 @@ def _nearest_owner(graph, modpath, scope) -> str | None:
|
|
|
1001
1104
|
|
|
1002
1105
|
def _collect_nested_calls(graph, owner, fnode, modpath, imports, modmembers,
|
|
1003
1106
|
script, target_pkg, out, known_modules=frozenset(),
|
|
1004
|
-
reexports=None) -> None:
|
|
1005
|
-
"""Resolve a non-node function's own calls and stage them under ``owner`` (D3).
|
|
1107
|
+
reexports=None, calls=None) -> None:
|
|
1108
|
+
"""Resolve a non-node function's own calls and stage them under ``owner`` (D3).
|
|
1109
|
+
|
|
1110
|
+
``fnode`` may also be a class nested in a function (R1-C74): its body runs inside the
|
|
1111
|
+
owner, like a closure's. Every site is counted into the owner's ``extras.calls_nested``.
|
|
1112
|
+
"""
|
|
1006
1113
|
def fast(call):
|
|
1007
1114
|
return _resolve(call, modpath, "", imports, modmembers, {}, reexports, graph.nodes)
|
|
1008
1115
|
# R1-C26: the same union the named-function path uses — a closure's calls must not be
|
|
1009
1116
|
# resolved by a weaker rule than its neighbours'.
|
|
1010
1117
|
resolve = (_deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules)
|
|
1011
1118
|
if script is not None else fast)
|
|
1012
|
-
|
|
1119
|
+
counts = graph.nodes[owner].extras.setdefault("calls_nested", _new_counts())
|
|
1120
|
+
for call in (calls if calls is not None else _own_calls(fnode)):
|
|
1013
1121
|
target, resolution = resolve(call)
|
|
1014
|
-
if
|
|
1122
|
+
if not _tally(counts, graph, target, resolution):
|
|
1015
1123
|
continue
|
|
1016
1124
|
if target == owner:
|
|
1017
1125
|
continue # a closure calling its own enclosing function — recursion, not a dep
|
|
@@ -109,7 +109,11 @@ SCHEMA_VERSION = "0.13"
|
|
|
109
109
|
# `check --graph` carries a currency line, and its structured answer a `graph_currency`
|
|
110
110
|
# block, with a graph behind its source tree failing the gate (R1-C70,
|
|
111
111
|
# docs/design/check_graph_currency.md).
|
|
112
|
-
|
|
112
|
+
# 4 — `report behavior` states the whole and its parts: call sites in function bodies,
|
|
113
|
+
# nested functions, module-level code and class bodies, plus sites in shadowed
|
|
114
|
+
# bodies that are not walked; the structured answer gains `by_scope` and
|
|
115
|
+
# `shadowed_not_walked` (R1-C74, docs/design/call_site_conservation.md).
|
|
116
|
+
ANSWER_FORMAT = 4
|
|
113
117
|
|
|
114
118
|
# Closed vocabulary of edge types (R1-C7). Node ``kind`` is deliberately an OPEN set
|
|
115
119
|
# (DESIGN §2 — new entity kinds may appear), but edges are TYPED: every relationship
|
|
@@ -103,15 +103,37 @@ def build_dead_code(query: Query, *, whitelist: tuple[str, ...] = (),
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
def call_sites_by_scope(graph) -> dict:
|
|
107
|
+
"""Call-site counters summed by the scope that runs them (R1-C74, design D2).
|
|
108
|
+
|
|
109
|
+
``function`` — own bodies of function nodes (attribute nodes are properties: functions too);
|
|
110
|
+
``nested`` — closures and classes inside them, carried by their owner; ``module`` —
|
|
111
|
+
module-level code; ``class_body`` — class bodies outside methods. Plus the sites in
|
|
112
|
+
shadowed bodies, which are counted but never walked.
|
|
113
|
+
"""
|
|
114
|
+
keys = ("out", "resolved", "external", "unresolved", "dynamic")
|
|
115
|
+
by = {s: dict.fromkeys(keys, 0) for s in ("function", "nested", "module", "class_body")}
|
|
116
|
+
shadowed = 0
|
|
117
|
+
for n in graph.nodes.values():
|
|
118
|
+
scope = {"function": "function", "attribute": "function", "module": "module",
|
|
119
|
+
"class": "class_body"}.get(n.kind)
|
|
120
|
+
for key, bucket in (("calls", scope), ("calls_nested", "nested")):
|
|
121
|
+
c = n.extras.get(key)
|
|
122
|
+
if c and bucket:
|
|
123
|
+
for k in keys:
|
|
124
|
+
by[bucket][k] += c.get(k, 0)
|
|
125
|
+
shadowed += n.extras.get("calls_shadowed", 0)
|
|
126
|
+
every = {k: sum(by[s][k] for s in by) for k in keys}
|
|
127
|
+
return {"all": every, "by_scope": by, "shadowed_not_walked": shadowed}
|
|
128
|
+
|
|
129
|
+
|
|
106
130
|
def build_behavior(query: Query) -> dict:
|
|
107
131
|
""":func:`render_behavior`, structured (R1-C32)."""
|
|
108
132
|
graph = query.graph
|
|
109
133
|
funcs = [n for n in graph.nodes.values() if n.kind == "function"]
|
|
110
134
|
with_cov = [n for n in funcs if "calls" in n.extras]
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
for k, v in n.extras["calls"].items():
|
|
114
|
-
agg[k] = agg.get(k, 0) + v
|
|
135
|
+
sites = call_sites_by_scope(graph)
|
|
136
|
+
agg = sites["all"]
|
|
115
137
|
by_res: dict[str, int] = {}
|
|
116
138
|
for e in graph.edges:
|
|
117
139
|
if e.type == "calls":
|
|
@@ -122,7 +144,11 @@ def build_behavior(query: Query) -> dict:
|
|
|
122
144
|
return {
|
|
123
145
|
"kind": "behavior",
|
|
124
146
|
"target": graph.target,
|
|
125
|
-
"call_sites": {**agg, "functions_with_coverage": len(with_cov)
|
|
147
|
+
"call_sites": {**agg, "functions_with_coverage": len(with_cov),
|
|
148
|
+
# R1-C74: the whole and its parts; they sum to every call in the source
|
|
149
|
+
# that can run (shadowed bodies are named, not walked).
|
|
150
|
+
"by_scope": sites["by_scope"],
|
|
151
|
+
"shadowed_not_walked": sites["shadowed_not_walked"]},
|
|
126
152
|
"calls_edges": {"total": sum(by_res.values()), "by_resolution": dict(sorted(by_res.items()))},
|
|
127
153
|
"complexity": {
|
|
128
154
|
"functions_scored": len(scored),
|
|
@@ -236,10 +262,8 @@ def render_behavior(query: Query) -> str:
|
|
|
236
262
|
graph = query.graph
|
|
237
263
|
funcs = [n for n in graph.nodes.values() if n.kind == "function"]
|
|
238
264
|
with_cov = [n for n in funcs if "calls" in n.extras]
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
for k, v in n.extras["calls"].items():
|
|
242
|
-
agg[k] += v
|
|
265
|
+
sites = call_sites_by_scope(graph)
|
|
266
|
+
agg, by = sites["all"], sites["by_scope"]
|
|
243
267
|
total = agg["out"] or 1
|
|
244
268
|
lines = [f"# Behavioral layer — `{graph.target}`", ""]
|
|
245
269
|
lines.append(
|
|
@@ -247,7 +271,17 @@ def render_behavior(query: Query) -> str:
|
|
|
247
271
|
"type inference and are left unresolved on purpose (gaps/ CM-09/10)._"
|
|
248
272
|
)
|
|
249
273
|
lines.append("")
|
|
250
|
-
lines.append(f"## Call-site resolution ({agg['out']} sites
|
|
274
|
+
lines.append(f"## Call-site resolution ({agg['out']} call sites)")
|
|
275
|
+
lines.append("")
|
|
276
|
+
# R1-C74: name the parts. The heading used to count function bodies only and read as the
|
|
277
|
+
# whole package; module-level code, nested functions and class bodies were outside it.
|
|
278
|
+
shadow = sites["shadowed_not_walked"]
|
|
279
|
+
lines.append(
|
|
280
|
+
f"_{by['function']['out']} in function bodies, {by['nested']['out']} in nested functions, "
|
|
281
|
+
f"{by['module']['out']} at module level, {by['class_body']['out']} in class bodies"
|
|
282
|
+
+ (f"; {shadow} more sit in bodies replaced by a later definition and are not walked "
|
|
283
|
+
"(they cannot run)" if shadow else "")
|
|
284
|
+
+ "._")
|
|
251
285
|
lines.append("")
|
|
252
286
|
lines.append(f"- resolved to internal edges: **{agg['resolved']}** ({100*agg['resolved']/total:.1f}%)")
|
|
253
287
|
lines.append(f"- external / builtin (flagged): {agg['external']} ({100*agg['external']/total:.1f}%)")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codmap
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.24
|
|
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, **1128 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 &
|
|
@@ -144,6 +144,8 @@ tests/test_r1c6_pack.py
|
|
|
144
144
|
tests/test_r1c70_check_graph_currency.py
|
|
145
145
|
tests/test_r1c71_attribute_chain_calls.py
|
|
146
146
|
tests/test_r1c72_flat_inference_in_packages.py
|
|
147
|
+
tests/test_r1c73_cli_version.py
|
|
148
|
+
tests/test_r1c74_call_site_conservation.py
|
|
147
149
|
tests/test_r1c7_edge_vocab.py
|
|
148
150
|
tests/test_r1c8_deadcode.py
|
|
149
151
|
tests/test_r1c9_incremental.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.24"
|
|
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
|
|
@@ -43,7 +43,8 @@ from codemap.serve.session import Session
|
|
|
43
43
|
# blind-spot line — wording inside an existing note, not a change of structure.
|
|
44
44
|
# Re-pinned with a bump to 3 (2026-11-03, R1-C69/R1-C70): the refusal of an invalid contract and
|
|
45
45
|
# the three states of the `check --graph` currency line join the pinned surfaces.
|
|
46
|
-
|
|
46
|
+
# Re-pinned with a bump to 4 (2026-11-03, R1-C74): `report behavior` names its scopes.
|
|
47
|
+
RENDERED_SHA = "32a160c89e31af252873c0703d0e903397c6d470013fad5889b9194e2a3a2eb8"
|
|
47
48
|
|
|
48
49
|
FILES = {
|
|
49
50
|
"__init__.py": "",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""R1-C73 — `codemap --version`, from the identity the graph records.
|
|
2
|
+
|
|
3
|
+
Design `docs/design/cli_version.md`. Pinned: V1 (agrees with `importlib.metadata` and with
|
|
4
|
+
`provenance.tool`), V3 (the flag wins over a subcommand), V4 (no arguments is still an error), V5 (no other
|
|
5
|
+
command pays for `tool_identity()`), and V6 (a fixed string goes red). V2, the wheel outside the source
|
|
6
|
+
tree, is the CI wheel job's ground and was measured by hand at acceptance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
from unittest import mock
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from codemap import cli, provenance
|
|
17
|
+
from codemap.model import ANSWER_FORMAT, SCHEMA_VERSION
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _version_out(capsys, *argv) -> tuple[int, str]:
|
|
21
|
+
with pytest.raises(SystemExit) as exc:
|
|
22
|
+
cli.main(["--version", *argv])
|
|
23
|
+
return exc.value.code, capsys.readouterr().out.strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_it_agrees_with_the_metadata_and_with_provenance(capsys):
|
|
27
|
+
rc, out = _version_out(capsys)
|
|
28
|
+
assert rc == 0
|
|
29
|
+
ident = provenance.tool_identity()
|
|
30
|
+
assert out.startswith(f"codemap {importlib.metadata.version('codmap')}")
|
|
31
|
+
if ident.get("commit"):
|
|
32
|
+
assert f"@{ident['commit']}" in out
|
|
33
|
+
assert out.endswith(f"· answer format {ANSWER_FORMAT} · schema {SCHEMA_VERSION}")
|
|
34
|
+
assert ("(dirty)" in out) == bool(ident.get("dirty"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_the_flag_wins_over_a_subcommand(capsys):
|
|
38
|
+
rc, out = _version_out(capsys, "build", "x")
|
|
39
|
+
assert rc == 0 and out.startswith("codemap ")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_no_arguments_is_still_an_error(capsys):
|
|
43
|
+
with pytest.raises(SystemExit) as exc:
|
|
44
|
+
cli.main([])
|
|
45
|
+
assert exc.value.code == 2
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_an_uninstalled_tool_says_so_rather_than_inventing_a_number():
|
|
49
|
+
with mock.patch.object(provenance, "tool_identity", lambda: {"name": "codemap"}):
|
|
50
|
+
line = cli.version_line()
|
|
51
|
+
assert line.startswith("codemap version unknown (not installed as a distribution)")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_no_other_command_pays_for_the_identity(tmp_path, capsys):
|
|
55
|
+
"""V5: building the parser must not call git; only the flag does."""
|
|
56
|
+
pkg = tmp_path / "vpkg"
|
|
57
|
+
pkg.mkdir()
|
|
58
|
+
(pkg / "__init__.py").write_text("")
|
|
59
|
+
g = tmp_path / "g.json"
|
|
60
|
+
assert cli.main(["build", str(pkg), "-o", str(g)]) == 0
|
|
61
|
+
with mock.patch.object(provenance, "tool_identity", side_effect=AssertionError("called")):
|
|
62
|
+
assert cli.main(["query", "vpkg", "--graph", str(g)]) in (0, 1)
|
|
63
|
+
assert cli.main(["report", "dependencies", "--graph", str(g)]) in (0, None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_mutation_a_fixed_string_goes_red(capsys):
|
|
67
|
+
"""V6: an action that prints a frozen version disagrees with the metadata."""
|
|
68
|
+
with mock.patch.object(cli, "version_line", lambda: "codemap 9.9.9 · answer format 1 · schema 0.1"):
|
|
69
|
+
_, out = _version_out(capsys)
|
|
70
|
+
assert not out.startswith(f"codemap {importlib.metadata.version('codmap')}")
|
|
71
|
+
_, out = _version_out(capsys)
|
|
72
|
+
assert out.startswith(f"codemap {importlib.metadata.version('codmap')}")
|