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.
Files changed (55) hide show
  1. codemap/__init__.py +10 -0
  2. codemap/apidiff.py +208 -0
  3. codemap/arch.py +190 -0
  4. codemap/cli.py +718 -0
  5. codemap/diagnostics.py +256 -0
  6. codemap/extract/__init__.py +10 -0
  7. codemap/extract/attrflow.py +230 -0
  8. codemap/extract/behavior.py +771 -0
  9. codemap/extract/dataflow.py +97 -0
  10. codemap/extract/dispatch.py +248 -0
  11. codemap/extract/griffe_extractor.py +496 -0
  12. codemap/extract/gsource.py +83 -0
  13. codemap/extract/roots.py +427 -0
  14. codemap/freshness.py +94 -0
  15. codemap/incremental.py +195 -0
  16. codemap/integrations/__init__.py +51 -0
  17. codemap/integrations/base.py +196 -0
  18. codemap/integrations/cocoindex.py +78 -0
  19. codemap/integrations/gate.py +58 -0
  20. codemap/integrations/gitnexus.py +93 -0
  21. codemap/integrations/registry.py +69 -0
  22. codemap/integrations/transport.py +46 -0
  23. codemap/model.py +178 -0
  24. codemap/provenance.py +248 -0
  25. codemap/query.py +1164 -0
  26. codemap/scope.py +212 -0
  27. codemap/serve/__init__.py +26 -0
  28. codemap/serve/_scip_pb2.py +100 -0
  29. codemap/serve/api_surface.py +60 -0
  30. codemap/serve/apidiff.py +83 -0
  31. codemap/serve/architecture.py +101 -0
  32. codemap/serve/audit.py +176 -0
  33. codemap/serve/check.py +80 -0
  34. codemap/serve/ctags.py +203 -0
  35. codemap/serve/impact.py +84 -0
  36. codemap/serve/livingdocs.py +174 -0
  37. codemap/serve/mcp_server.py +278 -0
  38. codemap/serve/mermaid.py +120 -0
  39. codemap/serve/pack.py +93 -0
  40. codemap/serve/rag.py +142 -0
  41. codemap/serve/review.py +197 -0
  42. codemap/serve/scip.py +183 -0
  43. codemap/serve/semantic.py +71 -0
  44. codemap/serve/server.py +43 -0
  45. codemap/serve/session.py +482 -0
  46. codemap/serve/subsystems.py +85 -0
  47. codemap/serve/vault.py +156 -0
  48. codemap/store.py +28 -0
  49. codemap/tomlio.py +59 -0
  50. codmap-0.0.3.dist-info/METADATA +245 -0
  51. codmap-0.0.3.dist-info/RECORD +55 -0
  52. codmap-0.0.3.dist-info/WHEEL +5 -0
  53. codmap-0.0.3.dist-info/entry_points.txt +2 -0
  54. codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
  55. codmap-0.0.3.dist-info/top_level.txt +1 -0
@@ -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