codmap 0.0.6__tar.gz → 0.0.8__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.6 → codmap-0.0.8}/PKG-INFO +2 -2
- {codmap-0.0.6 → codmap-0.0.8}/README.md +1 -1
- {codmap-0.0.6 → codmap-0.0.8}/codemap/cli.py +22 -1
- {codmap-0.0.6 → codmap-0.0.8}/codemap/diagnostics.py +39 -1
- {codmap-0.0.6 → codmap-0.0.8}/codemap/provenance.py +23 -5
- {codmap-0.0.6 → codmap-0.0.8}/codemap/scope.py +66 -5
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/session.py +11 -3
- {codmap-0.0.6 → codmap-0.0.8}/codmap.egg-info/PKG-INFO +2 -2
- {codmap-0.0.6 → codmap-0.0.8}/codmap.egg-info/SOURCES.txt +1 -0
- {codmap-0.0.6 → codmap-0.0.8}/pyproject.toml +1 -1
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c38_tool_drift.py +49 -0
- codmap-0.0.8/tests/test_r1c41_scope_membership.py +213 -0
- {codmap-0.0.6 → codmap-0.0.8}/LICENSE +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/__init__.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/apidiff.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/arch.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/extract/griffe_extractor.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/extract/roots.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/freshness.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/incremental.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/integrations/base.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/model.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/query.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/audit.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/check.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/impact.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/limits.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/pack.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/rag.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/review.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/scip.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/server.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/serve/vault.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/store.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/tomlio.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codemap/watch.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/setup.cfg +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_epistemic.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_integrations.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m15_review.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m1_query.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m2_views.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_m9_family.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_scip_export.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/tests/test_scope.py +0 -0
- {codmap-0.0.6 → codmap-0.0.8}/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.8
|
|
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, **743 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, **743 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 &
|
|
@@ -104,7 +104,7 @@ def _cmd_build(args) -> int:
|
|
|
104
104
|
# roots come from extract_repo in this same call; never inherited from a graph
|
|
105
105
|
# loaded off disk, which is how a stale scope would sneak into a fresh build.
|
|
106
106
|
roots=graph.provenance.get("roots") if (args.consumer or args.docs) else None,
|
|
107
|
-
inputs=graph
|
|
107
|
+
inputs=_inputs_with_membership(graph, scope, args))
|
|
108
108
|
# R1-C21: a well-formed but vacuous graph must announce itself — silence is what
|
|
109
109
|
# lets an unparsed layout read as a clean bill of health downstream.
|
|
110
110
|
for d in diagnostics(graph):
|
|
@@ -132,6 +132,27 @@ def _cmd_build(args) -> int:
|
|
|
132
132
|
return 0
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
def _inputs_with_membership(graph, scope, args):
|
|
136
|
+
"""The extractor's ``inputs`` block plus the manifest membership result (R1-C41).
|
|
137
|
+
|
|
138
|
+
Computed here because it is the one place that holds both sides: the graph and the
|
|
139
|
+
scope manifest that claims to describe its input. The *result* travels in the graph
|
|
140
|
+
(beside ``skipped``, which carries the same kind of fact) because build-time stderr
|
|
141
|
+
never reaches whoever reads the artifact an hour later, possibly without the sidecar.
|
|
142
|
+
|
|
143
|
+
Omitted entirely when no manifest resolved — that is "could not compare", and writing
|
|
144
|
+
a zero there would turn an unknown into a clean bill of health.
|
|
145
|
+
"""
|
|
146
|
+
inputs = dict(graph.provenance.get("inputs") or {})
|
|
147
|
+
if not scope:
|
|
148
|
+
return inputs or None
|
|
149
|
+
from codemap.scope import unlisted_files
|
|
150
|
+
base = roots_base(args.path, tuple(args.consumer or ()), tuple(args.docs or ()))
|
|
151
|
+
inputs["unlisted"] = unlisted_files(
|
|
152
|
+
(n.file for n in graph.nodes.values() if n.file), scope, base=base)
|
|
153
|
+
return inputs
|
|
154
|
+
|
|
155
|
+
|
|
135
156
|
def _resolve_scope_quietly(args):
|
|
136
157
|
"""The input scope manifest, or None. Never fatal — a build must not fail on it."""
|
|
137
158
|
from codemap.scope import resolve_scope
|
|
@@ -21,6 +21,7 @@ NO_CROSS_ROOT_EDGES = "no_cross_root_edges"
|
|
|
21
21
|
SCHEMA_MISMATCH = "schema_mismatch"
|
|
22
22
|
UNREAD_INPUTS = "unread_inputs"
|
|
23
23
|
MODULE_COUNT_MISMATCH = "module_count_mismatch"
|
|
24
|
+
SCOPE_MEMBERSHIP = "scope_membership"
|
|
24
25
|
|
|
25
26
|
#: A ``warning`` invalidates the conclusions a surface draws from the graph — read them as
|
|
26
27
|
#: unknown. A ``note`` states a fact about how the graph was built and invalidates nothing.
|
|
@@ -189,6 +190,42 @@ def unread_inputs_diagnostic(graph) -> dict | None:
|
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
|
|
193
|
+
def scope_membership_diagnostic(graph) -> dict | None:
|
|
194
|
+
"""Flag files the graph was built from that the input manifest never listed (R1-C41).
|
|
195
|
+
|
|
196
|
+
Sibling of the conservation law above, and it catches what that one provably cannot:
|
|
197
|
+
the count compares the *extractor's* walk against the graph, so when both agree and
|
|
198
|
+
only the **manifest** disagrees — an untracked or gitignored module, a file leaked in
|
|
199
|
+
from outside the target — it stays silent, correctly. Reported by the second real
|
|
200
|
+
target (issue #15), who found a sidecar listing 47 files with a hash on each beside a
|
|
201
|
+
graph built from 48.
|
|
202
|
+
|
|
203
|
+
Derived from ``provenance.inputs.unlisted``, which the build records because the
|
|
204
|
+
comparison needs the scope manifest and a consumer may hold only the graph. Absent
|
|
205
|
+
field means the build could not compare (no manifest resolved) — that is *unknown*,
|
|
206
|
+
not zero, so it is not reported as a clean result.
|
|
207
|
+
"""
|
|
208
|
+
unlisted = ((graph.provenance or {}).get("inputs") or {}).get("unlisted") or {}
|
|
209
|
+
count = unlisted.get("count") or 0
|
|
210
|
+
if not count:
|
|
211
|
+
return None
|
|
212
|
+
sample = ", ".join(unlisted.get("sample") or [])
|
|
213
|
+
where = (" (at least one lies outside the scope root entirely)"
|
|
214
|
+
if unlisted.get("outside_root") else "")
|
|
215
|
+
return {
|
|
216
|
+
"code": SCOPE_MEMBERSHIP,
|
|
217
|
+
"severity": WARNING,
|
|
218
|
+
"unlisted": count,
|
|
219
|
+
"consequence": ("The manifest and `scope_id` describe a different input than this "
|
|
220
|
+
"graph was built from, so read the input identity as **unknown** — "
|
|
221
|
+
"and with it `--incremental` and `watch`, which key off that value."),
|
|
222
|
+
"message": (
|
|
223
|
+
f"{count} file(s) in this graph are not listed in the input manifest{where}: "
|
|
224
|
+
f"{sample}" + (" …" if count > len(unlisted.get("sample") or []) else "")
|
|
225
|
+
),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
192
229
|
def module_count_diagnostic(graph) -> dict | None:
|
|
193
230
|
"""Conservation law over the build: modules cannot outnumber the files that define
|
|
194
231
|
them, nor silently fall short of them (R1-C23 / design D6).
|
|
@@ -236,7 +273,8 @@ def diagnostics(graph) -> list[dict]:
|
|
|
236
273
|
"""Every diagnostic that applies to ``graph`` (empty list when it looks sound)."""
|
|
237
274
|
checks = (import_graph_diagnostic(graph), namespace_target_diagnostic(graph),
|
|
238
275
|
cross_root_diagnostic(graph), schema_diagnostic(graph),
|
|
239
|
-
unread_inputs_diagnostic(graph), module_count_diagnostic(graph)
|
|
276
|
+
unread_inputs_diagnostic(graph), module_count_diagnostic(graph),
|
|
277
|
+
scope_membership_diagnostic(graph))
|
|
240
278
|
return [d for d in checks if d is not None]
|
|
241
279
|
|
|
242
280
|
|
|
@@ -43,12 +43,17 @@ MATCH, OLDER, NEWER, UNKNOWN = "match", "older", "newer", "unknown"
|
|
|
43
43
|
|
|
44
44
|
# -- tool identity (D2) -------------------------------------------------------
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"""The installed distribution's version, or None when it is not installed.
|
|
46
|
+
def installed_version() -> str | None:
|
|
47
|
+
"""What the distribution *currently on disk* says, read on every call.
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
R1-C38-f1. `tool_version()` below is cached for a whole process, which is right for
|
|
50
|
+
provenance — every graph a build writes must carry one version — and wrong for the
|
|
51
|
+
only question that needs the value twice: has the installation changed underneath a
|
|
52
|
+
long-running server? The cached reader answers with what was true at import, so a
|
|
53
|
+
comparison between the two is a comparison of one value with itself.
|
|
54
|
+
|
|
55
|
+
Measured before trusting it: an upgrade performed while a process runs *is* visible
|
|
56
|
+
to a fresh `importlib.metadata.version()`, with no `invalidate_caches()` needed.
|
|
52
57
|
"""
|
|
53
58
|
from importlib.metadata import PackageNotFoundError, version
|
|
54
59
|
try:
|
|
@@ -57,6 +62,19 @@ def tool_version() -> str | None:
|
|
|
57
62
|
return None
|
|
58
63
|
|
|
59
64
|
|
|
65
|
+
@lru_cache(maxsize=1)
|
|
66
|
+
def tool_version() -> str | None:
|
|
67
|
+
"""The installed distribution's version, or None when it is not installed.
|
|
68
|
+
|
|
69
|
+
The single source: `codemap.__version__` reads it too, having drifted a release
|
|
70
|
+
behind while it was a literal.
|
|
71
|
+
|
|
72
|
+
Cached on purpose — a build stamps one version into everything it writes. Anything
|
|
73
|
+
asking *"is this still true?"* rather than *"what was it?"* wants `installed_version()`.
|
|
74
|
+
"""
|
|
75
|
+
return installed_version()
|
|
76
|
+
|
|
77
|
+
|
|
60
78
|
|
|
61
79
|
@lru_cache(maxsize=8)
|
|
62
80
|
def _commit_of(root: Path) -> str | None:
|
|
@@ -90,8 +90,17 @@ def _pick_root(core: Path, consumers, docs) -> tuple[Path, str]:
|
|
|
90
90
|
return base, "fs"
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
def _enumerate_git(root: Path, roots: list[tuple[str, str]]) -> tuple[list[str], dict]:
|
|
94
|
-
"""git ls-files over the scope pathspecs → (rel paths, {path: git_blob}).
|
|
93
|
+
def _enumerate_git(root: Path, roots: list[tuple[str, str]]) -> tuple[list[str], dict, set[str]]:
|
|
94
|
+
"""git ls-files over the scope pathspecs → (rel paths, {path: git_blob}, untracked).
|
|
95
|
+
|
|
96
|
+
Two calls, because "the input" is not "the commit" (R1-C41). The tracked set is what
|
|
97
|
+
``git ls-files`` knows; ``--others --exclude-standard`` adds exactly what ``git add .``
|
|
98
|
+
would stage — a module that exists and has not been added yet is read by the extractor
|
|
99
|
+
like any other, so leaving it out made the manifest describe a different input than the
|
|
100
|
+
graph was built from, without moving ``scope_id``. Ignored files stay out on purpose:
|
|
101
|
+
if ``.gitignore`` says a file is not part of the tree, the manifest does not get to
|
|
102
|
+
decide otherwise — the membership check names them instead (design §1.7 D2).
|
|
103
|
+
"""
|
|
95
104
|
specs = [rp for rp, _ in roots if rp]
|
|
96
105
|
out = _git(root, "ls-files", "-s", "--", *specs) or ""
|
|
97
106
|
paths, blobs = [], {}
|
|
@@ -104,7 +113,9 @@ def _enumerate_git(root: Path, roots: list[tuple[str, str]]) -> tuple[list[str],
|
|
|
104
113
|
if len(cols) >= 2:
|
|
105
114
|
blobs[path] = cols[1]
|
|
106
115
|
paths.append(path)
|
|
107
|
-
|
|
116
|
+
others = _git(root, "ls-files", "--others", "--exclude-standard", "--", *specs) or ""
|
|
117
|
+
untracked = {p for p in others.splitlines() if p}
|
|
118
|
+
return paths + sorted(untracked), blobs, untracked
|
|
108
119
|
|
|
109
120
|
|
|
110
121
|
def _enumerate_fs(root: Path, roots, include, exclude_dirs) -> list[str]:
|
|
@@ -138,10 +149,10 @@ def resolve_scope(
|
|
|
138
149
|
roots = _roots_spec(root, core, consumers, docs)
|
|
139
150
|
|
|
140
151
|
if mode == "git":
|
|
141
|
-
rels, blobs = _enumerate_git(root, roots)
|
|
152
|
+
rels, blobs, untracked = _enumerate_git(root, roots)
|
|
142
153
|
rels = [r for r in rels if _match_include(Path(r).name, include)]
|
|
143
154
|
else:
|
|
144
|
-
rels, blobs = _enumerate_fs(root, roots, include, exclude_dirs), {}
|
|
155
|
+
rels, blobs, untracked = _enumerate_fs(root, roots, include, exclude_dirs), {}, set()
|
|
145
156
|
|
|
146
157
|
files = []
|
|
147
158
|
for rel in sorted(set(rels)):
|
|
@@ -155,6 +166,11 @@ def resolve_scope(
|
|
|
155
166
|
"loc": data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0)}
|
|
156
167
|
if rel in blobs:
|
|
157
168
|
rec["git_blob"] = blobs[rel]
|
|
169
|
+
if mode == "git":
|
|
170
|
+
# Always stated, never inferred from a missing key — absence is ambiguous
|
|
171
|
+
# (R1-C28's rule, applied to the manifest). fs mode omits it: there is no
|
|
172
|
+
# index to be tracked in, so the question does not arise.
|
|
173
|
+
rec["tracked"] = rel not in untracked
|
|
158
174
|
files.append(rec)
|
|
159
175
|
|
|
160
176
|
scope_id = "sha256:" + hashlib.sha256(
|
|
@@ -201,6 +217,51 @@ def _git_block(root: Path, roots: list[tuple[str, str]], mode: str) -> dict:
|
|
|
201
217
|
"dirty": bool(dirty_files), "dirty_files": dirty_files}
|
|
202
218
|
|
|
203
219
|
|
|
220
|
+
def unlisted_files(node_files, scope: dict, *, base: str | Path | None = None,
|
|
221
|
+
sample: int = 5) -> dict:
|
|
222
|
+
"""Files the graph was built from that the manifest does not list (R1-C41).
|
|
223
|
+
|
|
224
|
+
The manifest is the artifact a consumer reads to answer *"what exactly was
|
|
225
|
+
analyzed"*, and until this check existed it could answer wrong in silence: the
|
|
226
|
+
graph carried a module whose file the sidecar never mentioned, and ``scope_id`` —
|
|
227
|
+
the same value ``--incremental`` and ``watch`` key off — did not move.
|
|
228
|
+
|
|
229
|
+
**The two sides do not share an origin**, which is the whole difficulty. Node paths
|
|
230
|
+
are relative to the graph's own base (R1-C31: the parent of the package, or the
|
|
231
|
+
common ancestor of the roots), manifest paths are relative to the scope root (the
|
|
232
|
+
git top-level). On a ``src/`` layout those differ by a segment, and a naive string
|
|
233
|
+
comparison called both files of a healthy build unlisted — measured, 2 of 2. So the
|
|
234
|
+
node path is joined onto ``base`` and re-expressed against the scope root before it
|
|
235
|
+
is looked up; lexically, never through ``resolve()``, so a symlinked checkout does
|
|
236
|
+
not silently rewrite the answer.
|
|
237
|
+
|
|
238
|
+
Returns ``{count, sample, outside_root}`` — always, including ``count: 0``, because
|
|
239
|
+
a missing field is not the same statement as "nothing was unlisted". Paths outside
|
|
240
|
+
the scope root are reduced to a bare name: they are absolute (the D5 symptom of a
|
|
241
|
+
file the build should never have read), and the provenance block this ends up in
|
|
242
|
+
refuses absolute paths by contract.
|
|
243
|
+
"""
|
|
244
|
+
root = Path(scope.get("root") or ".")
|
|
245
|
+
listed = {f["path"] for f in scope.get("files") or ()}
|
|
246
|
+
unlisted: set[str] = set()
|
|
247
|
+
outside = False
|
|
248
|
+
for raw in node_files:
|
|
249
|
+
if not raw:
|
|
250
|
+
continue
|
|
251
|
+
p = Path(raw)
|
|
252
|
+
absolute = p if p.is_absolute() else Path(os.path.normpath(Path(base or root) / p))
|
|
253
|
+
try:
|
|
254
|
+
rel = absolute.relative_to(root).as_posix()
|
|
255
|
+
except ValueError:
|
|
256
|
+
outside = True
|
|
257
|
+
unlisted.add(p.name)
|
|
258
|
+
continue
|
|
259
|
+
if rel not in listed:
|
|
260
|
+
unlisted.add(rel)
|
|
261
|
+
return {"count": len(unlisted), "sample": sorted(unlisted)[:sample],
|
|
262
|
+
"outside_root": outside}
|
|
263
|
+
|
|
264
|
+
|
|
204
265
|
def diff_scopes(a: dict, b: dict) -> dict:
|
|
205
266
|
"""Added / removed / changed files between two scope manifests (by path+sha256)."""
|
|
206
267
|
am = {f["path"]: f["sha256"] for f in a.get("files", [])}
|
|
@@ -109,7 +109,11 @@ def tool_drift(running: str | None, installed: str | None) -> dict | None:
|
|
|
109
109
|
|
|
110
110
|
`stats` already separates the graph's schema from the running tool's. This is the same
|
|
111
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
|
|
112
|
+
installed metadata at call time while the code was loaded at import — which is a
|
|
113
|
+
property of *how the caller reads it*, not of this function. Shipped once without it
|
|
114
|
+
(0.0.6): `Session._tool_drift` passed the cached `tool_version()` on both sides, so the
|
|
115
|
+
two arguments were the same value by construction and this returned `None` forever.
|
|
116
|
+
See `installed_version()` and R1-C38-f1.
|
|
113
117
|
"""
|
|
114
118
|
if not running or not installed or running == installed:
|
|
115
119
|
return None
|
|
@@ -306,9 +310,13 @@ class Session:
|
|
|
306
310
|
|
|
307
311
|
@staticmethod
|
|
308
312
|
def _tool_drift() -> dict | None:
|
|
313
|
+
# The two sides must be read differently or there is nothing to compare (R1-C38-f1):
|
|
314
|
+
# `codemap.__version__` is fixed at import — that is the *running* code — while the
|
|
315
|
+
# installed distribution is re-read here, on every call. Using the cached
|
|
316
|
+
# `tool_version()` on the right made this compare a value with itself.
|
|
309
317
|
import codemap
|
|
310
|
-
from codemap.provenance import
|
|
311
|
-
return tool_drift(getattr(codemap, "__version__", None),
|
|
318
|
+
from codemap.provenance import installed_version
|
|
319
|
+
return tool_drift(getattr(codemap, "__version__", None), installed_version())
|
|
312
320
|
|
|
313
321
|
def _op_reload(self, args) -> dict:
|
|
314
322
|
"""Reload the on-disk artifact into the served graph, without a restart (#3).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codmap
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
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, **743 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 &
|
|
@@ -108,6 +108,7 @@ tests/test_r1c37_rule_mutation.py
|
|
|
108
108
|
tests/test_r1c38_tool_drift.py
|
|
109
109
|
tests/test_r1c3_arch_contract.py
|
|
110
110
|
tests/test_r1c3_dogfood.py
|
|
111
|
+
tests/test_r1c41_scope_membership.py
|
|
111
112
|
tests/test_r1c4_complexity.py
|
|
112
113
|
tests/test_r1c5_apidiff.py
|
|
113
114
|
tests/test_r1c6_pack.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.8"
|
|
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
|
|
@@ -53,6 +53,55 @@ def test_an_unknown_version_is_not_a_drift():
|
|
|
53
53
|
assert tool_drift(None, None) is None
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def _shadow_installed_version(tmp_path, monkeypatch, version: str) -> None:
|
|
57
|
+
"""Make the *installed* distribution look like `version`, the way an upgrade would.
|
|
58
|
+
|
|
59
|
+
A `dist-info` directory earlier on `sys.path` wins, so this changes what
|
|
60
|
+
`importlib.metadata` answers without touching the imported module — exactly the
|
|
61
|
+
asymmetry a real upgrade under a running server creates.
|
|
62
|
+
"""
|
|
63
|
+
d = tmp_path / "site"
|
|
64
|
+
(d / f"codmap-{version}.dist-info").mkdir(parents=True)
|
|
65
|
+
(d / f"codmap-{version}.dist-info" / "METADATA").write_text(
|
|
66
|
+
f"Metadata-Version: 2.1\nName: codmap\nVersion: {version}\n")
|
|
67
|
+
monkeypatch.syspath_prepend(str(d))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_the_installed_side_is_read_fresh_or_there_is_nothing_to_compare(tmp_path, monkeypatch):
|
|
71
|
+
"""R1-C38-f1 — the defect 0.0.6 shipped, and the reason no test caught it.
|
|
72
|
+
|
|
73
|
+
Every other test here hands `tool_drift` two versions, or forces a difference by
|
|
74
|
+
patching `codemap.__version__`. Both prove the arithmetic. Neither asks the question
|
|
75
|
+
the feature exists for: *can the condition arise?* It could not — `_tool_drift` read
|
|
76
|
+
the installed side through `tool_version()`, which is `lru_cache`d and first called at
|
|
77
|
+
import by `codemap/__init__.py`, so both sides were the same cached value forever.
|
|
78
|
+
|
|
79
|
+
Here nothing about the process is patched. Only the installation changes, as an
|
|
80
|
+
upgrade changes it, and the running server must notice.
|
|
81
|
+
"""
|
|
82
|
+
from codemap.serve.session import Session
|
|
83
|
+
|
|
84
|
+
assert Session._tool_drift() is None # agreement before the "upgrade"
|
|
85
|
+
_shadow_installed_version(tmp_path, monkeypatch, "9.9.9")
|
|
86
|
+
|
|
87
|
+
d = Session._tool_drift()
|
|
88
|
+
assert d is not None, "the installed version is being read from a process-lifetime cache"
|
|
89
|
+
assert d["code"] == "tool_restart_needed" and d["installed"] == "9.9.9"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_the_build_side_stays_cached(tmp_path, monkeypatch):
|
|
93
|
+
"""The fix must not turn provenance into a per-call filesystem read: a build stamps
|
|
94
|
+
one version into everything it writes, so `tool_version()` keeps its cache. The two
|
|
95
|
+
readers exist because they answer different questions — "what was it?" and "is it
|
|
96
|
+
still?" — and this is the assertion that keeps them apart."""
|
|
97
|
+
from codemap.provenance import installed_version, tool_version
|
|
98
|
+
|
|
99
|
+
before = tool_version()
|
|
100
|
+
_shadow_installed_version(tmp_path, monkeypatch, "9.9.9")
|
|
101
|
+
assert tool_version() == before
|
|
102
|
+
assert installed_version() == "9.9.9"
|
|
103
|
+
|
|
104
|
+
|
|
56
105
|
def test_stats_and_reload_carry_it_when_it_applies(tmp_path, monkeypatch):
|
|
57
106
|
import codemap
|
|
58
107
|
from codemap.extract import extract
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""R1-C41 — the manifest must describe the input the graph was actually built from.
|
|
2
|
+
|
|
3
|
+
Raised by the second real target (issue #15): their sidecar listed 47 files with a
|
|
4
|
+
`git_blob` and a `sha256` on each while the graph carried 48, and the artifact a
|
|
5
|
+
consumer reads to answer *"what exactly was analyzed"* answered wrong in silence.
|
|
6
|
+
|
|
7
|
+
Measuring it made the defect wider than the report — no decoy directory is needed. In
|
|
8
|
+
git mode the manifest enumerated the *tracked* set while the extractor walked the
|
|
9
|
+
*filesystem*, so an untracked module (one that exists and has not been `git add`ed) or a
|
|
10
|
+
gitignored one was in the graph, absent from `scope.files`, and moved `scope_id` not at
|
|
11
|
+
all. Since `scope_id` is the cache key for `--incremental` and the `watch` probe, that is
|
|
12
|
+
a correctness defect and not a reporting one: `--incremental` printed
|
|
13
|
+
`unchanged: 0 module(s) recomputed` over a file that had just grown a new symbol.
|
|
14
|
+
|
|
15
|
+
Every test here drives the real CLI over a real git repo. The point is not the arithmetic
|
|
16
|
+
of a comparison — it is whether the condition can arise at all, which is the lesson 0.0.6
|
|
17
|
+
paid for.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import pytest
|
|
28
|
+
|
|
29
|
+
from codemap import cli
|
|
30
|
+
from codemap.diagnostics import SCOPE_MEMBERSHIP, scope_membership_diagnostic
|
|
31
|
+
from codemap.provenance import build_provenance
|
|
32
|
+
from codemap.scope import resolve_scope, unlisted_files
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _git(base: Path, *args: str) -> None:
|
|
36
|
+
subprocess.run(("git", "-C", str(base), *args), check=True,
|
|
37
|
+
capture_output=True, text=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def repo(tmp_path) -> Path:
|
|
42
|
+
"""A git repo whose .gitignore excludes a *source* file the extractor still reads."""
|
|
43
|
+
if not shutil.which("git"):
|
|
44
|
+
pytest.skip("git not available")
|
|
45
|
+
(tmp_path / "pkg").mkdir()
|
|
46
|
+
(tmp_path / "pkg" / "__init__.py").write_text("")
|
|
47
|
+
(tmp_path / "pkg" / "core.py").write_text("def a():\n return 1\n")
|
|
48
|
+
(tmp_path / ".gitignore").write_text("pkg/generated_*.py\n")
|
|
49
|
+
_git(tmp_path, "init", "-q")
|
|
50
|
+
_git(tmp_path, "config", "user.email", "t@t")
|
|
51
|
+
_git(tmp_path, "config", "user.name", "t")
|
|
52
|
+
_git(tmp_path, "add", "-A")
|
|
53
|
+
_git(tmp_path, "commit", "-qm", "init")
|
|
54
|
+
return tmp_path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build(repo: Path, out: Path, *extra: str) -> tuple[dict, dict]:
|
|
58
|
+
assert cli.main(["build", str(repo / "pkg"), "-o", str(out), *extra]) == 0
|
|
59
|
+
graph = json.loads(out.read_text(encoding="utf-8"))
|
|
60
|
+
meta = json.loads(Path(str(out) + ".meta.json").read_text(encoding="utf-8"))
|
|
61
|
+
return graph, meta
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _files(meta: dict) -> dict[str, dict]:
|
|
65
|
+
return {f["path"]: f for f in meta["scope"]["files"]}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# -- D1: enumerate what will be read, not what the commit remembers ------------
|
|
69
|
+
|
|
70
|
+
def test_an_untracked_module_enters_the_manifest_and_moves_the_identity(repo, tmp_path):
|
|
71
|
+
before, meta_before = _build(repo, tmp_path / "a.json")
|
|
72
|
+
(repo / "pkg" / "untracked.py").write_text("def brand_new():\n return 42\n")
|
|
73
|
+
after, meta_after = _build(repo, tmp_path / "b.json")
|
|
74
|
+
|
|
75
|
+
assert "pkg/untracked.py" not in _files(meta_before)
|
|
76
|
+
assert "pkg/untracked.py" in _files(meta_after), (
|
|
77
|
+
"a module that exists and has not been `git add`ed is read by the extractor; "
|
|
78
|
+
"leaving it out of the manifest describes a different input than the graph")
|
|
79
|
+
# …and the identity has to move, or every cache keyed on it is wrong (below).
|
|
80
|
+
assert before["provenance"]["scope_id"] != after["provenance"]["scope_id"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_incremental_recomputes_an_untracked_edit(repo, tmp_path):
|
|
84
|
+
out = tmp_path / "inc.json"
|
|
85
|
+
(repo / "pkg" / "untracked.py").write_text("def brand_new():\n return 42\n")
|
|
86
|
+
_build(repo, out)
|
|
87
|
+
(repo / "pkg" / "untracked.py").write_text(
|
|
88
|
+
"def brand_new():\n return 42\n\n\ndef added_later():\n return 7\n")
|
|
89
|
+
graph, _ = _build(repo, out, "--incremental")
|
|
90
|
+
assert any(n["id"].endswith("added_later") for n in graph["nodes"]), (
|
|
91
|
+
"scope_id is the incremental cache key: if an untracked edit does not move it, "
|
|
92
|
+
"the rebuild reports `unchanged` and serves a stale graph as current")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_git_mode_states_tracked_on_every_record_and_fs_mode_omits_it(repo):
|
|
96
|
+
scope = resolve_scope(repo / "pkg")
|
|
97
|
+
(repo / "pkg" / "untracked.py").write_text("x = 1\n")
|
|
98
|
+
scope2 = resolve_scope(repo / "pkg")
|
|
99
|
+
recs = {f["path"]: f for f in scope2["files"]}
|
|
100
|
+
|
|
101
|
+
# stated on every record, never inferred from a missing key (R1-C28's rule)
|
|
102
|
+
assert all("tracked" in f for f in scope["files"])
|
|
103
|
+
assert recs["pkg/core.py"]["tracked"] is True
|
|
104
|
+
assert recs["pkg/untracked.py"]["tracked"] is False
|
|
105
|
+
assert "git_blob" not in recs["pkg/untracked.py"] # there is none to record
|
|
106
|
+
assert "git_blob" in recs["pkg/core.py"]
|
|
107
|
+
|
|
108
|
+
# fs mode has no index, so the question does not arise and nothing is claimed
|
|
109
|
+
fs = resolve_scope(repo / "pkg", use_git=False)
|
|
110
|
+
assert fs["files"] and all("tracked" not in f for f in fs["files"])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# -- D2: membership named, not adopted ----------------------------------------
|
|
114
|
+
|
|
115
|
+
def test_a_gitignored_module_is_named_and_not_quietly_adopted(repo, tmp_path, capsys):
|
|
116
|
+
(repo / "pkg" / "generated_version.py").write_text('def v():\n return "1.2.3"\n')
|
|
117
|
+
graph, meta = _build(repo, tmp_path / "g.json")
|
|
118
|
+
|
|
119
|
+
# the repo says this file is not part of the tree; the manifest does not overrule it
|
|
120
|
+
assert "pkg/generated_version.py" not in _files(meta)
|
|
121
|
+
# …and the graph says so out loud instead of leaving a hole
|
|
122
|
+
unlisted = graph["provenance"]["inputs"]["unlisted"]
|
|
123
|
+
assert unlisted["count"] == 1
|
|
124
|
+
assert unlisted["sample"] == ["pkg/generated_version.py"]
|
|
125
|
+
assert "not listed in the input manifest" in capsys.readouterr().err
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_the_count_check_stays_silent_on_exactly_this_state(repo, tmp_path):
|
|
129
|
+
"""The conservation law cannot catch it, which is why this check exists."""
|
|
130
|
+
(repo / "pkg" / "generated_version.py").write_text('def v():\n return "1.2.3"\n')
|
|
131
|
+
graph, _ = _build(repo, tmp_path / "g.json")
|
|
132
|
+
from codemap.diagnostics import module_count_diagnostic
|
|
133
|
+
from codemap.store import load
|
|
134
|
+
|
|
135
|
+
g = load(str(tmp_path / "g.json"))
|
|
136
|
+
modules = sum(1 for n in g.nodes.values() if n.kind == "module")
|
|
137
|
+
assert modules == graph["provenance"]["inputs"]["python_files"] == 3
|
|
138
|
+
assert module_count_diagnostic(g) is None # right to be silent: it agrees
|
|
139
|
+
assert scope_membership_diagnostic(g)["code"] == SCOPE_MEMBERSHIP
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# -- the trap: the two sides do not share an origin ----------------------------
|
|
143
|
+
|
|
144
|
+
def test_a_src_layout_is_not_a_violation(tmp_path):
|
|
145
|
+
"""Node paths are root-relative to the graph's base, manifest paths to the repo.
|
|
146
|
+
|
|
147
|
+
On `src/pkg` those differ by a segment, and comparing the strings as they stand
|
|
148
|
+
called both files of a healthy build unlisted — 2 of 2, measured before the fix.
|
|
149
|
+
"""
|
|
150
|
+
if not shutil.which("git"):
|
|
151
|
+
pytest.skip("git not available")
|
|
152
|
+
(tmp_path / "src" / "pkg").mkdir(parents=True)
|
|
153
|
+
(tmp_path / "src" / "pkg" / "__init__.py").write_text("")
|
|
154
|
+
(tmp_path / "src" / "pkg" / "core.py").write_text("def a():\n return 1\n")
|
|
155
|
+
(tmp_path / "tests").mkdir()
|
|
156
|
+
(tmp_path / "tests" / "test_core.py").write_text("from pkg.core import a\n")
|
|
157
|
+
_git(tmp_path, "init", "-q")
|
|
158
|
+
_git(tmp_path, "config", "user.email", "t@t")
|
|
159
|
+
_git(tmp_path, "config", "user.name", "t")
|
|
160
|
+
_git(tmp_path, "add", "-A")
|
|
161
|
+
_git(tmp_path, "commit", "-qm", "init")
|
|
162
|
+
|
|
163
|
+
out = tmp_path / "s.json"
|
|
164
|
+
assert cli.main(["build", str(tmp_path / "src" / "pkg"), "-o", str(out)]) == 0
|
|
165
|
+
core_only = json.loads(out.read_text(encoding="utf-8"))
|
|
166
|
+
assert cli.main(["build", str(tmp_path / "src" / "pkg"), "--consumer",
|
|
167
|
+
str(tmp_path / "tests"), "-o", str(out)]) == 0
|
|
168
|
+
repo_scoped = json.loads(out.read_text(encoding="utf-8"))
|
|
169
|
+
|
|
170
|
+
assert core_only["provenance"]["inputs"]["unlisted"]["count"] == 0
|
|
171
|
+
assert repo_scoped["provenance"]["inputs"]["unlisted"]["count"] == 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_a_file_outside_the_root_is_reduced_to_a_bare_name(tmp_path):
|
|
175
|
+
"""The files this check catches are the ones most likely to be absolute — and the
|
|
176
|
+
provenance block refuses an absolute path by contract, so a naive record would turn
|
|
177
|
+
the diagnostic into a build crash."""
|
|
178
|
+
scope = {"root": str(tmp_path), "files": [{"path": "pkg/core.py"}]}
|
|
179
|
+
result = unlisted_files(["pkg/core.py", "/elsewhere/decoy/impostor.py"], scope)
|
|
180
|
+
|
|
181
|
+
assert result["count"] == 1
|
|
182
|
+
assert result["sample"] == ["impostor.py"] # not the absolute path
|
|
183
|
+
assert result["outside_root"] is True
|
|
184
|
+
# the contract that would have been violated (build_provenance raises on absolutes)
|
|
185
|
+
block = build_provenance(tier="fast", inputs={"python_files": 1, "unlisted": result})
|
|
186
|
+
assert block["inputs"]["unlisted"]["outside_root"] is True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# -- zero is a statement; unknown is an omission -------------------------------
|
|
190
|
+
|
|
191
|
+
def test_zero_is_stated_and_no_manifest_is_left_unstated(repo, tmp_path, monkeypatch):
|
|
192
|
+
graph, _ = _build(repo, tmp_path / "clean.json")
|
|
193
|
+
assert graph["provenance"]["inputs"]["unlisted"] == {
|
|
194
|
+
"count": 0, "sample": [], "outside_root": False}
|
|
195
|
+
|
|
196
|
+
from codemap import cli as cli_mod
|
|
197
|
+
monkeypatch.setattr(cli_mod, "_resolve_scope_quietly", lambda args: None)
|
|
198
|
+
out = tmp_path / "noscope.json"
|
|
199
|
+
assert cli.main(["build", str(repo / "pkg"), "-o", str(out)]) == 0
|
|
200
|
+
unresolved = json.loads(out.read_text(encoding="utf-8"))
|
|
201
|
+
assert "unlisted" not in unresolved["provenance"]["inputs"], (
|
|
202
|
+
"no manifest means the build could not compare — writing a zero there would "
|
|
203
|
+
"turn an unknown into a clean bill of health")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_the_diagnostic_reports_nothing_when_there_is_nothing_to_report():
|
|
207
|
+
class _G:
|
|
208
|
+
provenance = {"inputs": {"unlisted": {"count": 0, "sample": [],
|
|
209
|
+
"outside_root": False}}}
|
|
210
|
+
|
|
211
|
+
assert scope_membership_diagnostic(_G()) is None
|
|
212
|
+
_G.provenance = {"inputs": {}} # pre-R1-C41 graph
|
|
213
|
+
assert scope_membership_diagnostic(_G()) is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|