codmap 0.0.3__py3-none-any.whl
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.
- codemap/__init__.py +10 -0
- codemap/apidiff.py +208 -0
- codemap/arch.py +190 -0
- codemap/cli.py +718 -0
- codemap/diagnostics.py +256 -0
- codemap/extract/__init__.py +10 -0
- codemap/extract/attrflow.py +230 -0
- codemap/extract/behavior.py +771 -0
- codemap/extract/dataflow.py +97 -0
- codemap/extract/dispatch.py +248 -0
- codemap/extract/griffe_extractor.py +496 -0
- codemap/extract/gsource.py +83 -0
- codemap/extract/roots.py +427 -0
- codemap/freshness.py +94 -0
- codemap/incremental.py +195 -0
- codemap/integrations/__init__.py +51 -0
- codemap/integrations/base.py +196 -0
- codemap/integrations/cocoindex.py +78 -0
- codemap/integrations/gate.py +58 -0
- codemap/integrations/gitnexus.py +93 -0
- codemap/integrations/registry.py +69 -0
- codemap/integrations/transport.py +46 -0
- codemap/model.py +178 -0
- codemap/provenance.py +248 -0
- codemap/query.py +1164 -0
- codemap/scope.py +212 -0
- codemap/serve/__init__.py +26 -0
- codemap/serve/_scip_pb2.py +100 -0
- codemap/serve/api_surface.py +60 -0
- codemap/serve/apidiff.py +83 -0
- codemap/serve/architecture.py +101 -0
- codemap/serve/audit.py +176 -0
- codemap/serve/check.py +80 -0
- codemap/serve/ctags.py +203 -0
- codemap/serve/impact.py +84 -0
- codemap/serve/livingdocs.py +174 -0
- codemap/serve/mcp_server.py +278 -0
- codemap/serve/mermaid.py +120 -0
- codemap/serve/pack.py +93 -0
- codemap/serve/rag.py +142 -0
- codemap/serve/review.py +197 -0
- codemap/serve/scip.py +183 -0
- codemap/serve/semantic.py +71 -0
- codemap/serve/server.py +43 -0
- codemap/serve/session.py +482 -0
- codemap/serve/subsystems.py +85 -0
- codemap/serve/vault.py +156 -0
- codemap/store.py +28 -0
- codemap/tomlio.py +59 -0
- codmap-0.0.3.dist-info/METADATA +245 -0
- codmap-0.0.3.dist-info/RECORD +55 -0
- codmap-0.0.3.dist-info/WHEEL +5 -0
- codmap-0.0.3.dist-info/entry_points.txt +2 -0
- codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
- codmap-0.0.3.dist-info/top_level.txt +1 -0
codemap/extract/roots.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Multi-root extraction — repo scope / impact (DESIGN §10.12, M6).
|
|
2
|
+
|
|
3
|
+
The single-package :func:`~codemap.extract.griffe_extractor.extract` sees only the
|
|
4
|
+
core package, so blast-radius questions ("who uses X / can I delete it") miss the
|
|
5
|
+
consumers that live *outside* the package — tests, examples, scripts, docs. That
|
|
6
|
+
was the dominant gap the dogfood found (gaps/observability_dogfood_2026-07-28, F1).
|
|
7
|
+
|
|
8
|
+
:func:`extract_repo` keeps the core on griffe (deep import/inheritance resolution)
|
|
9
|
+
and adds **consumer** and **doc** roots by a light stdlib-``ast`` / regex scan for
|
|
10
|
+
references *into the core*. Consumers are typically loose script dirs (no package
|
|
11
|
+
``__init__``), so we do not griffe-load them — we only need their edges into core.
|
|
12
|
+
|
|
13
|
+
Every node carries provenance in ``extras.root`` (``core`` | the consumer dir name
|
|
14
|
+
| ``docs``). Two modes, both real, selectable for empirical comparison:
|
|
15
|
+
|
|
16
|
+
- **thin** (default): a consumer file is one ``module`` node; its uses of core
|
|
17
|
+
symbols become ``references``/``calls`` edges from that file. Cheap; answers
|
|
18
|
+
"which files/roots reference X". No consumer-internal structure.
|
|
19
|
+
- **full**: consumer functions/classes are materialized as nodes (``contains``),
|
|
20
|
+
and each use edge is sourced from the enclosing function — "which test *function*
|
|
21
|
+
calls X". Richer, more nodes/noise.
|
|
22
|
+
|
|
23
|
+
Docs (``*.md``) can't be ``ast``-parsed; a doc file becomes a ``doc`` node with
|
|
24
|
+
``references`` edges to every core symbol it names via ``from core… import`` /
|
|
25
|
+
exact dotted mention (piggybacking the doc-parity convention).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import ast
|
|
31
|
+
import re
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from codemap.extract.behavior import _arg_contract, _arg_shape
|
|
35
|
+
from codemap.extract.griffe_extractor import extract
|
|
36
|
+
from codemap.provenance import canonicalize
|
|
37
|
+
from codemap.model import Edge, Graph, Node
|
|
38
|
+
|
|
39
|
+
_CONSUMER_SKIP_DIRS = {"__pycache__", ".venv", ".git", "node_modules"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_repo(
|
|
43
|
+
core: str | Path,
|
|
44
|
+
*,
|
|
45
|
+
consumers: tuple[str | Path, ...] = (),
|
|
46
|
+
docs: tuple[str | Path, ...] = (),
|
|
47
|
+
mode: str = "thin",
|
|
48
|
+
deep: bool = False,
|
|
49
|
+
) -> Graph:
|
|
50
|
+
"""Build a repo-scoped graph: core package + consumer roots + doc roots.
|
|
51
|
+
|
|
52
|
+
``core`` is analysed exactly as the single-package extractor (griffe, plus the
|
|
53
|
+
behavioral pass when ``deep``). ``consumers`` and ``docs`` are extra root
|
|
54
|
+
directories scanned for references into the core. ``mode`` is ``"thin"`` or
|
|
55
|
+
``"full"`` (see module docstring).
|
|
56
|
+
"""
|
|
57
|
+
if mode not in ("thin", "full"):
|
|
58
|
+
raise ValueError(f"mode must be 'thin' or 'full', got {mode!r}")
|
|
59
|
+
|
|
60
|
+
graph = extract(core, deep=deep)
|
|
61
|
+
core_pkg = graph.target
|
|
62
|
+
for node in graph.nodes.values():
|
|
63
|
+
node.extras.setdefault("root", "core")
|
|
64
|
+
|
|
65
|
+
index = _CoreIndex(graph, core_pkg)
|
|
66
|
+
for path in consumers:
|
|
67
|
+
_scan_consumer_root(graph, Path(path).resolve(), index, mode)
|
|
68
|
+
for path in docs:
|
|
69
|
+
_scan_doc_root(graph, Path(path).resolve(), index)
|
|
70
|
+
# R1-C25: the roots are part of what this graph *is* — a core-only graph and a
|
|
71
|
+
# repo-scoped one answer `impact` differently, and `diff` must not silently compare
|
|
72
|
+
# the two. Names only, never locations (design D5).
|
|
73
|
+
graph.provenance = canonicalize({**graph.provenance, "roots": {
|
|
74
|
+
"core": Path(core).name,
|
|
75
|
+
"consumers": sorted(Path(p).name for p in consumers),
|
|
76
|
+
"docs": sorted(Path(p).name for p in docs),
|
|
77
|
+
"mode": mode,
|
|
78
|
+
}})
|
|
79
|
+
return graph
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# -- core resolver: dotted reference (incl. re-exports) -> canonical node id ---
|
|
83
|
+
|
|
84
|
+
class _CoreIndex:
|
|
85
|
+
"""Resolve a dotted path that names a core symbol to its canonical node id.
|
|
86
|
+
|
|
87
|
+
Handles the re-export case: ``from bquant.indicators import MACDZoneAnalyzer``
|
|
88
|
+
names ``bquant.indicators.MACDZoneAnalyzer`` (the re-export path), which the
|
|
89
|
+
core graph exposes via an ``export`` edge to the canonical
|
|
90
|
+
``bquant.indicators.macd.MACDZoneAnalyzer``.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, graph: Graph, core_pkg: str):
|
|
94
|
+
self.core_pkg = core_pkg
|
|
95
|
+
self.node_ids = set(graph.nodes)
|
|
96
|
+
self.module_ids = sorted(
|
|
97
|
+
(n.id for n in graph.nodes.values() if n.kind == "module"),
|
|
98
|
+
key=len,
|
|
99
|
+
reverse=True,
|
|
100
|
+
)
|
|
101
|
+
# R1-C21-f1 (issue #6): whether the *core* is itself a flat layout, which decides
|
|
102
|
+
# if a consumer's bare-name import can reach it at all. Structural, not statistical:
|
|
103
|
+
# a properly packaged core is never on sys.path, so `from alpha import f` in a
|
|
104
|
+
# script cannot be reaching `core/alpha.py` — inferring an edge there would invent
|
|
105
|
+
# one. Both flat shapes satisfy this: a namespace directory (root has no file), or a
|
|
106
|
+
# directory whose own imports needed the flat inference.
|
|
107
|
+
root = graph.nodes.get(core_pkg)
|
|
108
|
+
self.core_is_flat = (root is not None and root.file is None) or any(
|
|
109
|
+
e.type == "imports" and e.extras.get("resolution") == "flat" for e in graph.edges
|
|
110
|
+
)
|
|
111
|
+
self.top_modules = {
|
|
112
|
+
mid.split(".", 1)[1].split(".")[0]
|
|
113
|
+
for mid in self.module_ids
|
|
114
|
+
if mid.startswith(core_pkg + ".")
|
|
115
|
+
}
|
|
116
|
+
self.exports: dict[str, str] = {}
|
|
117
|
+
for e in graph.edges:
|
|
118
|
+
if e.type == "export":
|
|
119
|
+
self.exports[f"{e.source}.{e.extras.get('as')}"] = e.target
|
|
120
|
+
|
|
121
|
+
def is_core(self, qualname: str) -> bool:
|
|
122
|
+
return qualname == self.core_pkg or qualname.startswith(self.core_pkg + ".")
|
|
123
|
+
|
|
124
|
+
def qualify_flat(self, module_name: str) -> str | None:
|
|
125
|
+
"""Core-qualify a consumer's **bare-name** import, or None (R1-C21-f1, issue #6).
|
|
126
|
+
|
|
127
|
+
`from alpha import f` in a consumer root names the same module that a sibling
|
|
128
|
+
inside the package names that way — the flat layout puts the core directory on
|
|
129
|
+
`sys.path`, so both reach it. Gated on ``core_is_flat`` (see ``__init__``) and on
|
|
130
|
+
the head naming a real top-level core module, so it is inert on a packaged core.
|
|
131
|
+
"""
|
|
132
|
+
if not self.core_is_flat or self.is_core(module_name):
|
|
133
|
+
return None
|
|
134
|
+
if module_name.split(".")[0] not in self.top_modules:
|
|
135
|
+
return None
|
|
136
|
+
return f"{self.core_pkg}.{module_name}"
|
|
137
|
+
|
|
138
|
+
def resolve(self, qualname: str) -> str | None:
|
|
139
|
+
"""Canonical node id for a core-qualified path, or None if out of core."""
|
|
140
|
+
if not self.is_core(qualname):
|
|
141
|
+
return None
|
|
142
|
+
if qualname in self.node_ids:
|
|
143
|
+
return qualname
|
|
144
|
+
if qualname in self.exports:
|
|
145
|
+
return self.exports[qualname]
|
|
146
|
+
# longest prefix that is a node or a re-export, then re-append the rest.
|
|
147
|
+
parts = qualname.split(".")
|
|
148
|
+
for cut in range(len(parts) - 1, 0, -1):
|
|
149
|
+
prefix = ".".join(parts[:cut])
|
|
150
|
+
base = self.exports.get(prefix) or (prefix if prefix in self.node_ids else None)
|
|
151
|
+
if base is None:
|
|
152
|
+
continue
|
|
153
|
+
candidate = base + "." + ".".join(parts[cut:])
|
|
154
|
+
return candidate if candidate in self.node_ids else base
|
|
155
|
+
# fallback: the core module that contains it (still a real node).
|
|
156
|
+
return self._containing_module(qualname)
|
|
157
|
+
|
|
158
|
+
def _containing_module(self, qualname: str) -> str | None:
|
|
159
|
+
for mid in self.module_ids: # sorted longest-first
|
|
160
|
+
if qualname == mid or qualname.startswith(mid + "."):
|
|
161
|
+
return mid
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# -- consumer roots (.py) ----------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def _scan_consumer_root(graph: Graph, root_dir: Path, index: _CoreIndex, mode: str) -> None:
|
|
168
|
+
if not root_dir.is_dir():
|
|
169
|
+
return
|
|
170
|
+
label = root_dir.name
|
|
171
|
+
base = root_dir.parent
|
|
172
|
+
for py in sorted(root_dir.rglob("*.py")):
|
|
173
|
+
if any(part in _CONSUMER_SKIP_DIRS for part in py.parts):
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
tree = ast.parse(py.read_text(encoding="utf-8"))
|
|
177
|
+
except (OSError, SyntaxError):
|
|
178
|
+
continue
|
|
179
|
+
_scan_consumer_module(graph, py, base, label, tree, index, mode)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _module_id(py: Path, base: Path) -> str:
|
|
183
|
+
rel = py.resolve().relative_to(base)
|
|
184
|
+
parts = list(rel.with_suffix("").parts)
|
|
185
|
+
if parts and parts[-1] == "__init__":
|
|
186
|
+
parts = parts[:-1]
|
|
187
|
+
return ".".join(parts)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _scan_consumer_module(graph, py, base, label, tree, index, mode) -> None:
|
|
191
|
+
mod_id = _module_id(py, base)
|
|
192
|
+
rel = str(py.resolve().relative_to(base))
|
|
193
|
+
graph.add_node(Node(id=mod_id, kind="module", file=rel, extras={"root": label}))
|
|
194
|
+
|
|
195
|
+
symbol_map, module_aliases, flat_targets = _consumer_imports(tree, index)
|
|
196
|
+
# module -> core module `imports` edges (dedup).
|
|
197
|
+
seen_imp: set[str] = set()
|
|
198
|
+
for tgt in list(symbol_map.values()) + [p for p in module_aliases.values()]:
|
|
199
|
+
cm = index.resolve(tgt)
|
|
200
|
+
cm = cm if cm in index.node_ids and _is_module(graph, cm) else index._containing_module(tgt)
|
|
201
|
+
if cm and cm not in seen_imp:
|
|
202
|
+
seen_imp.add(cm)
|
|
203
|
+
# R1-C21-f1: label the sys.path inference on the import edge, exactly as the
|
|
204
|
+
# in-package resolution does; the calls/references it enables stay "imported".
|
|
205
|
+
extras = {"resolution": "flat"} if tgt in flat_targets else {}
|
|
206
|
+
graph.add_edge(Edge("imports", mod_id, cm, extras=extras))
|
|
207
|
+
|
|
208
|
+
if mode == "full":
|
|
209
|
+
_materialize_defs(graph, tree, mod_id, label)
|
|
210
|
+
|
|
211
|
+
# use edges: (source_id, target_id) -> {called?, arg shapes at call-sites}.
|
|
212
|
+
uses: dict[tuple[str, str], dict] = {}
|
|
213
|
+
call_by_func = {id(n.func): n for n in ast.walk(tree) if isinstance(n, ast.Call)}
|
|
214
|
+
inner_attr_ids = {
|
|
215
|
+
id(n.value) for n in ast.walk(tree)
|
|
216
|
+
if isinstance(n, ast.Attribute) and isinstance(n.value, ast.Attribute)
|
|
217
|
+
}
|
|
218
|
+
func_ranges = _func_ranges(tree) if mode == "full" else []
|
|
219
|
+
|
|
220
|
+
for node in ast.walk(tree):
|
|
221
|
+
target = None
|
|
222
|
+
use_node = node
|
|
223
|
+
if isinstance(node, ast.Name) and node.id in symbol_map:
|
|
224
|
+
target = index.resolve(symbol_map[node.id])
|
|
225
|
+
elif isinstance(node, ast.Attribute) and id(node) not in inner_attr_ids:
|
|
226
|
+
dotted = _dotted(node)
|
|
227
|
+
if dotted:
|
|
228
|
+
head, _, rest = dotted.partition(".")
|
|
229
|
+
if head in module_aliases:
|
|
230
|
+
full = module_aliases[head] + ("." + rest if rest else "")
|
|
231
|
+
target = index.resolve(full)
|
|
232
|
+
if not target:
|
|
233
|
+
continue
|
|
234
|
+
src = _source_for(use_node, mod_id, func_ranges) if mode == "full" else mod_id
|
|
235
|
+
entry = uses.setdefault((src, target), {"called": False, "shapes": []})
|
|
236
|
+
call_node = call_by_func.get(id(use_node))
|
|
237
|
+
if call_node is not None:
|
|
238
|
+
entry["called"] = True
|
|
239
|
+
entry["shapes"].append(_arg_shape(call_node)) # F7: capture call-site contract
|
|
240
|
+
|
|
241
|
+
for (src, target), entry in sorted(uses.items()):
|
|
242
|
+
etype = "calls" if entry["called"] else "references"
|
|
243
|
+
extras = {"resolution": "imported"}
|
|
244
|
+
if etype == "calls" and entry["shapes"]:
|
|
245
|
+
extras.update(_arg_contract(entry["shapes"]))
|
|
246
|
+
graph.add_edge(Edge(etype, src, target, extras=extras))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _consumer_imports(tree, index: _CoreIndex):
|
|
250
|
+
"""Return (symbol_map, module_aliases) for imports that reach into core.
|
|
251
|
+
|
|
252
|
+
``symbol_map``: local name -> core-qualified symbol path (``from core.x import Y``).
|
|
253
|
+
``module_aliases``: local root name -> core module prefix (``import core.x [as z]``).
|
|
254
|
+
"""
|
|
255
|
+
symbol_map: dict[str, str] = {}
|
|
256
|
+
module_aliases: dict[str, str] = {}
|
|
257
|
+
flat_targets: set[str] = set()
|
|
258
|
+
for node in ast.walk(tree):
|
|
259
|
+
if isinstance(node, ast.ImportFrom):
|
|
260
|
+
if node.level or not node.module:
|
|
261
|
+
continue
|
|
262
|
+
module, flat = node.module, False
|
|
263
|
+
if not index.is_core(module):
|
|
264
|
+
# R1-C21-f1: a bare-name import of a core module, in a flat layout.
|
|
265
|
+
qualified = index.qualify_flat(module)
|
|
266
|
+
if qualified is None:
|
|
267
|
+
continue
|
|
268
|
+
module, flat = qualified, True
|
|
269
|
+
for alias in node.names:
|
|
270
|
+
if alias.name == "*":
|
|
271
|
+
module_aliases.setdefault(module.split(".")[0], module)
|
|
272
|
+
if flat:
|
|
273
|
+
flat_targets.add(module)
|
|
274
|
+
continue
|
|
275
|
+
target = f"{module}.{alias.name}"
|
|
276
|
+
symbol_map[alias.asname or alias.name] = target
|
|
277
|
+
if flat:
|
|
278
|
+
flat_targets.add(target)
|
|
279
|
+
elif isinstance(node, ast.Import):
|
|
280
|
+
for alias in node.names:
|
|
281
|
+
name, flat = alias.name, False
|
|
282
|
+
if not index.is_core(name):
|
|
283
|
+
qualified = index.qualify_flat(name)
|
|
284
|
+
if qualified is None:
|
|
285
|
+
continue
|
|
286
|
+
name, flat = qualified, True
|
|
287
|
+
if alias.asname:
|
|
288
|
+
module_aliases[alias.asname] = name
|
|
289
|
+
else:
|
|
290
|
+
# a flat `import alpha` binds the bare name, not the core prefix.
|
|
291
|
+
local = (alias.name if flat else name).split(".")[0]
|
|
292
|
+
module_aliases[local] = name if flat else name.split(".")[0]
|
|
293
|
+
if flat:
|
|
294
|
+
flat_targets.add(name)
|
|
295
|
+
return symbol_map, module_aliases, flat_targets
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _dotted(node) -> str | None:
|
|
299
|
+
parts: list[str] = []
|
|
300
|
+
cur = node
|
|
301
|
+
while isinstance(cur, ast.Attribute):
|
|
302
|
+
parts.append(cur.attr)
|
|
303
|
+
cur = cur.value
|
|
304
|
+
if isinstance(cur, ast.Name):
|
|
305
|
+
parts.append(cur.id)
|
|
306
|
+
return ".".join(reversed(parts))
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _is_module(graph, node_id) -> bool:
|
|
311
|
+
n = graph.nodes.get(node_id)
|
|
312
|
+
return n is not None and n.kind == "module"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# -- full mode: materialize consumer defs -----------------------------------
|
|
316
|
+
|
|
317
|
+
def _materialize_defs(graph, tree, mod_id, label) -> None:
|
|
318
|
+
for fnode, class_stack in _defs(tree):
|
|
319
|
+
node_id = ".".join([mod_id, *class_stack, fnode.name])
|
|
320
|
+
kind = "class" if isinstance(fnode, ast.ClassDef) else "function"
|
|
321
|
+
graph.add_node(Node(id=node_id, kind=kind, lineno=fnode.lineno,
|
|
322
|
+
extras={"root": label}))
|
|
323
|
+
parent = ".".join([mod_id, *class_stack]) if class_stack else mod_id
|
|
324
|
+
graph.add_edge(Edge("contains", parent, node_id))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _defs(tree):
|
|
328
|
+
"""Yield (def-node, [enclosing class/def names]) for top-level & nested defs."""
|
|
329
|
+
results = []
|
|
330
|
+
|
|
331
|
+
def visit(node, stack):
|
|
332
|
+
for child in ast.iter_child_nodes(node):
|
|
333
|
+
if isinstance(child, ast.ClassDef):
|
|
334
|
+
results.append((child, list(stack)))
|
|
335
|
+
visit(child, stack + [child.name])
|
|
336
|
+
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
337
|
+
results.append((child, list(stack)))
|
|
338
|
+
visit(child, stack + [child.name])
|
|
339
|
+
else:
|
|
340
|
+
visit(child, stack)
|
|
341
|
+
|
|
342
|
+
visit(tree, [])
|
|
343
|
+
return results
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _func_ranges(tree):
|
|
347
|
+
"""(start, end, node_id) for each def, longest-first, to place a use by line."""
|
|
348
|
+
ranges = []
|
|
349
|
+
|
|
350
|
+
def visit(node, mod_stack):
|
|
351
|
+
for child in ast.iter_child_nodes(node):
|
|
352
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
353
|
+
nid = ".".join([*mod_stack, child.name])
|
|
354
|
+
end = getattr(child, "end_lineno", child.lineno)
|
|
355
|
+
ranges.append((child.lineno, end, nid))
|
|
356
|
+
visit(child, mod_stack + [child.name])
|
|
357
|
+
else:
|
|
358
|
+
visit(child, mod_stack)
|
|
359
|
+
|
|
360
|
+
visit(tree, [])
|
|
361
|
+
# inner scopes first so a use inside a nested def is attributed to it.
|
|
362
|
+
ranges.sort(key=lambda r: (r[1] - r[0]))
|
|
363
|
+
return ranges
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _source_for(use_node, mod_id, func_ranges) -> str:
|
|
367
|
+
line = getattr(use_node, "lineno", None)
|
|
368
|
+
if line is not None:
|
|
369
|
+
for start, end, nid in func_ranges: # smallest-range first
|
|
370
|
+
if start <= line <= end:
|
|
371
|
+
return f"{mod_id}.{nid}"
|
|
372
|
+
return mod_id
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# -- doc roots (.md) ---------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def _doc_patterns(core_pkg: str):
|
|
378
|
+
"""Regexes keyed to the core package name (not hardcoded)."""
|
|
379
|
+
pkg = re.escape(core_pkg)
|
|
380
|
+
from_import = re.compile(rf"from\s+({pkg}[\w.]*)\s+import\s+([^\n#]+)")
|
|
381
|
+
dotted = re.compile(rf"\b{pkg}(?:\.[A-Za-z_]\w*)+\b")
|
|
382
|
+
return from_import, dotted
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _scan_doc_root(graph: Graph, root_dir: Path, index: _CoreIndex) -> None:
|
|
386
|
+
if not root_dir.is_dir():
|
|
387
|
+
return
|
|
388
|
+
base = root_dir.parent
|
|
389
|
+
patterns = _doc_patterns(index.core_pkg)
|
|
390
|
+
for md in sorted(root_dir.rglob("*.md")):
|
|
391
|
+
try:
|
|
392
|
+
text = md.read_text(encoding="utf-8")
|
|
393
|
+
except OSError:
|
|
394
|
+
continue
|
|
395
|
+
_scan_doc_file(graph, md, base, text, index, patterns)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _scan_doc_file(graph, md, base, text, index, patterns) -> None:
|
|
399
|
+
from_import, dotted = patterns
|
|
400
|
+
rel = str(md.resolve().relative_to(base))
|
|
401
|
+
doc_id = rel
|
|
402
|
+
targets: set[str] = set()
|
|
403
|
+
|
|
404
|
+
# from-import lines pin re-exported symbols precisely.
|
|
405
|
+
for module, names in from_import.findall(text):
|
|
406
|
+
if not index.is_core(module):
|
|
407
|
+
continue
|
|
408
|
+
for raw in names.replace("(", " ").replace(")", " ").split(","):
|
|
409
|
+
name = raw.strip().split(" as ")[0].strip()
|
|
410
|
+
if not name or name == "*":
|
|
411
|
+
continue
|
|
412
|
+
tgt = index.resolve(f"{module}.{name}")
|
|
413
|
+
if tgt:
|
|
414
|
+
targets.add(tgt)
|
|
415
|
+
|
|
416
|
+
# exact dotted mentions that resolve to a real node (filters prose noise).
|
|
417
|
+
for token in dotted.findall(text):
|
|
418
|
+
if token in index.node_ids:
|
|
419
|
+
targets.add(token)
|
|
420
|
+
elif token in index.exports:
|
|
421
|
+
targets.add(index.exports[token])
|
|
422
|
+
|
|
423
|
+
if not targets:
|
|
424
|
+
return
|
|
425
|
+
graph.add_node(Node(id=doc_id, kind="doc", file=rel, extras={"root": "docs"}))
|
|
426
|
+
for tgt in sorted(targets):
|
|
427
|
+
graph.add_edge(Edge("references", doc_id, tgt, extras={"resolution": "doc"}))
|
codemap/freshness.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Graph freshness (M18, a first step of the deferred M3.2).
|
|
2
|
+
|
|
3
|
+
The canonical ``graph.json`` is deterministic and **timestamp-free** by design, so
|
|
4
|
+
freshness metadata lives *outside* it:
|
|
5
|
+
|
|
6
|
+
- the graph file's **mtime** gives build time — works for any graph, no cooperation
|
|
7
|
+
needed — surfaced as ``age_seconds`` in ``stats`` so an agent knows the map may be
|
|
8
|
+
stale;
|
|
9
|
+
- an optional **sidecar** ``<graph>.meta.json`` records the exact build invocation so
|
|
10
|
+
a stale graph can be rebuilt with one command (``codemap refresh``).
|
|
11
|
+
|
|
12
|
+
No timestamps ever enter ``graph.json`` itself — determinism is preserved.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def meta_path(graph_path: str) -> str:
|
|
24
|
+
"""Sidecar path for a graph file (``graph.json`` → ``graph.json.meta.json``)."""
|
|
25
|
+
return str(graph_path) + ".meta.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_meta(graph_path: str, *, argv: list[str], cwd: str, target: str,
|
|
29
|
+
scope: dict | None = None) -> None:
|
|
30
|
+
"""Record the build recipe beside a freshly-written graph (best-effort).
|
|
31
|
+
|
|
32
|
+
``scope`` (M19.A) is the input manifest — ``{scope_id, profile, git, files}`` —
|
|
33
|
+
so a graph's exact input is provable/diffable. Provenance, not structure: it lives
|
|
34
|
+
in the sidecar, never in the timestamp-free ``graph.json``.
|
|
35
|
+
"""
|
|
36
|
+
meta = {"built_at": round(time.time()), "argv": list(argv),
|
|
37
|
+
"cwd": cwd, "target": target}
|
|
38
|
+
if scope is not None:
|
|
39
|
+
meta["scope"] = scope
|
|
40
|
+
try:
|
|
41
|
+
Path(meta_path(graph_path)).write_text(
|
|
42
|
+
json.dumps(meta, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
43
|
+
except OSError:
|
|
44
|
+
pass # freshness metadata is a convenience, never fatal
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_meta(graph_path: str) -> dict | None:
|
|
48
|
+
"""The build recipe sidecar for a graph file, or None if absent/unreadable."""
|
|
49
|
+
try:
|
|
50
|
+
return json.loads(Path(meta_path(graph_path)).read_text(encoding="utf-8"))
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def freshness(graph_path: str | None, *, now: float | None = None,
|
|
56
|
+
served_mtime: float | None = None) -> dict | None:
|
|
57
|
+
"""Freshness of a graph: ``{built_at, age_seconds, rebuild?, stale?, ...}`` or None.
|
|
58
|
+
|
|
59
|
+
``built_at``/``age_seconds`` describe the graph **actually in hand**. For a one-shot
|
|
60
|
+
read that is the on-disk file's mtime. For a long-lived server that cached the graph
|
|
61
|
+
at start, pass ``served_mtime`` (the file mtime captured when it loaded): then the
|
|
62
|
+
report is about the *served* snapshot, and if the on-disk file has since advanced,
|
|
63
|
+
it is flagged ``stale: True`` with ``on_disk_built_at`` + a ``reason`` — never the
|
|
64
|
+
silent "fresh" that issue #3 caught (a stale answer labelled current). ``rebuild``
|
|
65
|
+
(the recorded ``argv``/``cwd``) is present only when a sidecar exists.
|
|
66
|
+
"""
|
|
67
|
+
if not graph_path:
|
|
68
|
+
return None
|
|
69
|
+
now = time.time() if now is None else now
|
|
70
|
+
try:
|
|
71
|
+
disk_mtime = os.path.getmtime(graph_path)
|
|
72
|
+
except OSError:
|
|
73
|
+
disk_mtime = None
|
|
74
|
+
if disk_mtime is None and served_mtime is None:
|
|
75
|
+
return None # nothing on disk and nothing served — unknown
|
|
76
|
+
|
|
77
|
+
base = served_mtime if served_mtime is not None else disk_mtime
|
|
78
|
+
out = {"built_at": round(base), "age_seconds": max(0, round(now - base))}
|
|
79
|
+
# On-disk divergence: the served snapshot is older than the artifact on disk
|
|
80
|
+
# (an external rebuild happened) — or the artifact is gone. Say so, don't reassure.
|
|
81
|
+
if served_mtime is not None:
|
|
82
|
+
if disk_mtime is None:
|
|
83
|
+
out["stale"] = True
|
|
84
|
+
out["reason"] = ("the on-disk graph is gone since this server loaded it; "
|
|
85
|
+
"restart to serve a current graph")
|
|
86
|
+
elif disk_mtime > served_mtime + 1e-6:
|
|
87
|
+
out["stale"] = True
|
|
88
|
+
out["on_disk_built_at"] = round(disk_mtime)
|
|
89
|
+
out["reason"] = ("the on-disk graph was rebuilt after this server loaded "
|
|
90
|
+
"it; call `reload` (or restart) to serve the current graph")
|
|
91
|
+
meta = read_meta(graph_path)
|
|
92
|
+
if meta and meta.get("argv"):
|
|
93
|
+
out["rebuild"] = {"argv": meta["argv"], "cwd": meta.get("cwd")}
|
|
94
|
+
return out
|