code_intel.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 days ago
| 1 | """Knowtation code-intelligence helpers backing ``muse code deps`` / ``impact``. |
| 2 | |
| 3 | This module sits between the CLI commands (``muse code deps`` and |
| 4 | ``muse code impact``) and the link index (Phase 3.1). It produces |
| 5 | JSON-shaped results that mirror the structure used by the Code domain so the |
| 6 | two commands can be invoked uniformly regardless of the active domain. |
| 7 | |
| 8 | Public API |
| 9 | ---------- |
| 10 | - :func:`note_deps` — direct forward / reverse note-link queries for ``deps``. |
| 11 | - :func:`note_impact` — transitive blast-radius BFS for ``impact``. |
| 12 | - :func:`describe_link` — JSON-friendly representation of a single edge. |
| 13 | |
| 14 | Design notes |
| 15 | ------------ |
| 16 | |
| 17 | The graph backing both ``deps`` and ``impact`` for vaults is the |
| 18 | :class:`~muse.plugins.knowtation.link_index.LinkIndex`. For ``deps``: |
| 19 | |
| 20 | - **Forward (deps)** = outgoing links from a note (the note's "imports"). |
| 21 | - **Reverse (deps --reverse)** = incoming links to a note (its "importers"). |
| 22 | |
| 23 | For ``impact``: |
| 24 | |
| 25 | - **Reverse BFS** = transitive set of notes whose changes propagate **to** the |
| 26 | target (notes that link to me, plus notes that link to those, …). |
| 27 | - **Forward BFS** (``--forward``) = transitive notes the target links to. |
| 28 | |
| 29 | By default, broken links are excluded from both queries. Pass |
| 30 | ``include_broken=True`` to opt in. |
| 31 | """ |
| 32 | |
| 33 | from __future__ import annotations |
| 34 | |
| 35 | import logging |
| 36 | import pathlib |
| 37 | from collections import deque |
| 38 | from typing import Any |
| 39 | |
| 40 | from muse.plugins.knowtation.link_index import ( |
| 41 | LinkIndex, |
| 42 | ResolvedLink, |
| 43 | build_link_index, |
| 44 | ) |
| 45 | |
| 46 | logger = logging.getLogger(__name__) |
| 47 | |
| 48 | |
| 49 | # --------------------------------------------------------------------------- |
| 50 | # Serialisation helpers |
| 51 | # --------------------------------------------------------------------------- |
| 52 | |
| 53 | |
| 54 | def describe_link(link: ResolvedLink) -> dict[str, Any]: |
| 55 | """Return a JSON-friendly representation of *link*. |
| 56 | |
| 57 | Args: |
| 58 | link: A :class:`ResolvedLink` from the link index. |
| 59 | |
| 60 | Returns: |
| 61 | Dict with keys: ``source``, ``target``, ``raw``, ``kind``, ``fragment``, |
| 62 | ``broken``, ``alias``. |
| 63 | """ |
| 64 | return { |
| 65 | "source": link.source, |
| 66 | "target": link.target, |
| 67 | "raw": link.raw, |
| 68 | "kind": link.kind, |
| 69 | "fragment": link.fragment, |
| 70 | "broken": link.broken, |
| 71 | "alias": link.alias, |
| 72 | } |
| 73 | |
| 74 | |
| 75 | # --------------------------------------------------------------------------- |
| 76 | # deps queries |
| 77 | # --------------------------------------------------------------------------- |
| 78 | |
| 79 | |
| 80 | def note_deps( |
| 81 | index: LinkIndex, |
| 82 | note_path: str, |
| 83 | *, |
| 84 | reverse: bool = False, |
| 85 | include_broken: bool = False, |
| 86 | kinds: tuple[str, ...] = ("wikilink", "md_link"), |
| 87 | ) -> dict[str, Any]: |
| 88 | """Return forward or reverse link results for a single note. |
| 89 | |
| 90 | Args: |
| 91 | index: Pre-built :class:`LinkIndex` for the active vault. |
| 92 | note_path: Vault-relative POSIX path of the note to query. |
| 93 | reverse: When ``True``, return incoming links instead of |
| 94 | outgoing. |
| 95 | include_broken: When ``False`` (default), broken links are filtered |
| 96 | out of the result. Only meaningful for forward queries. |
| 97 | kinds: Edge kinds to include. Defaults to ``("wikilink", |
| 98 | "md_link")``; pass e.g. ``("wikilink", "md_link", |
| 99 | "entity")`` to include entity co-occurrence edges. |
| 100 | |
| 101 | Returns: |
| 102 | Dict suitable for JSON output, with keys: |
| 103 | |
| 104 | - ``note``: the queried note path. |
| 105 | - ``direction``: ``"outgoing"`` or ``"incoming"``. |
| 106 | - ``count``: number of edges in ``links``. |
| 107 | - ``links``: list of edge dicts (see :func:`describe_link`). |
| 108 | """ |
| 109 | if reverse: |
| 110 | edges = index.incoming(note_path) |
| 111 | direction = "incoming" |
| 112 | else: |
| 113 | edges = index.outgoing(note_path) |
| 114 | direction = "outgoing" |
| 115 | |
| 116 | filtered: list[ResolvedLink] = [] |
| 117 | for link in edges: |
| 118 | if link.kind not in kinds: |
| 119 | continue |
| 120 | if link.broken and not include_broken: |
| 121 | continue |
| 122 | filtered.append(link) |
| 123 | |
| 124 | return { |
| 125 | "note": note_path, |
| 126 | "direction": direction, |
| 127 | "count": len(filtered), |
| 128 | "links": [describe_link(link) for link in filtered], |
| 129 | } |
| 130 | |
| 131 | |
| 132 | # --------------------------------------------------------------------------- |
| 133 | # impact (BFS) |
| 134 | # --------------------------------------------------------------------------- |
| 135 | |
| 136 | |
| 137 | def note_impact( |
| 138 | index: LinkIndex, |
| 139 | note_path: str, |
| 140 | *, |
| 141 | forward: bool = False, |
| 142 | depth: int = 0, |
| 143 | include_broken: bool = False, |
| 144 | include_attachments: bool = True, |
| 145 | ) -> dict[str, Any]: |
| 146 | """Compute the transitive blast radius for *note_path*. |
| 147 | |
| 148 | Args: |
| 149 | index: :class:`LinkIndex` for the active vault. |
| 150 | note_path: Vault-relative POSIX path of the seed note. |
| 151 | forward: When ``True``, walk outgoing edges (note's |
| 152 | dependencies). When ``False`` (default), walk |
| 153 | incoming edges (notes that depend on this note). |
| 154 | depth: Maximum BFS depth. ``0`` (default) means |
| 155 | unlimited. Must be non-negative. |
| 156 | include_broken: When ``True``, include broken edges in the walk. |
| 157 | They contribute no new nodes, so this only |
| 158 | affects the broken-link count in the result. |
| 159 | include_attachments: When ``True`` (default), notes sharing a mist |
| 160 | attachment ID are treated as entangled (Phase 3.5 |
| 161 | extension placeholder; this flag is honoured by |
| 162 | callers but the underlying join is added in 3.5). |
| 163 | |
| 164 | Returns: |
| 165 | Dict suitable for JSON output, with keys: |
| 166 | |
| 167 | - ``note``: the seed note path. |
| 168 | - ``direction``: ``"forward"`` (callees) or ``"reverse"`` (callers). |
| 169 | - ``depth_limit``: the configured maximum depth (``0`` for unlimited). |
| 170 | - ``by_depth``: dict mapping depth (str) → sorted list of note paths. |
| 171 | - ``total``: total number of distinct notes in the blast radius |
| 172 | (excluding the seed note itself). |
| 173 | - ``include_attachments``: echoed parameter. |
| 174 | |
| 175 | Raises: |
| 176 | ValueError: When *depth* is negative. |
| 177 | """ |
| 178 | if depth < 0: |
| 179 | raise ValueError(f"note_impact: depth must be non-negative, got {depth}") |
| 180 | |
| 181 | by_depth: dict[int, set[str]] = {} |
| 182 | visited: set[str] = {note_path} |
| 183 | frontier: deque[tuple[str, int]] = deque([(note_path, 0)]) |
| 184 | |
| 185 | # Phase 3.5: precompute note → mist_ids for O(1) attachment lookups. |
| 186 | note_to_mist: dict[str, set[str]] = {} |
| 187 | if include_attachments: |
| 188 | for mist_id, members in index.attachment_index.items(): |
| 189 | for member in members: |
| 190 | note_to_mist.setdefault(member, set()).add(mist_id) |
| 191 | |
| 192 | while frontier: |
| 193 | current, d = frontier.popleft() |
| 194 | |
| 195 | if depth != 0 and d >= depth: |
| 196 | continue |
| 197 | |
| 198 | if forward: |
| 199 | edges = index.outgoing(current) |
| 200 | else: |
| 201 | edges = index.incoming(current) |
| 202 | |
| 203 | next_depth = d + 1 |
| 204 | for link in edges: |
| 205 | # Forward BFS: follow link.target (where the current note points). |
| 206 | # Reverse BFS: follow link.source (which note links to current). |
| 207 | if forward: |
| 208 | neighbour = link.target |
| 209 | if neighbour is None: |
| 210 | if include_broken: |
| 211 | continue # broken forward edge contributes no neighbour |
| 212 | continue |
| 213 | else: |
| 214 | neighbour = link.source |
| 215 | |
| 216 | if not neighbour or neighbour in visited: |
| 217 | continue |
| 218 | visited.add(neighbour) |
| 219 | by_depth.setdefault(next_depth, set()).add(neighbour) |
| 220 | frontier.append((neighbour, next_depth)) |
| 221 | |
| 222 | # Phase 3.5: mist-attachment co-references add cross-edges at |
| 223 | # next_depth. Notes that share any mist ID with `current` are |
| 224 | # entangled and contribute to the blast radius regardless of forward |
| 225 | # vs reverse direction. |
| 226 | if include_attachments and current in note_to_mist: |
| 227 | for mist_id in note_to_mist[current]: |
| 228 | for neighbour in index.attachment_index.get(mist_id, ()): |
| 229 | if neighbour == current or neighbour in visited: |
| 230 | continue |
| 231 | visited.add(neighbour) |
| 232 | by_depth.setdefault(next_depth, set()).add(neighbour) |
| 233 | frontier.append((neighbour, next_depth)) |
| 234 | |
| 235 | serialised: dict[str, list[str]] = { |
| 236 | str(d): sorted(by_depth[d]) for d in sorted(by_depth) |
| 237 | } |
| 238 | total = sum(len(v) for v in by_depth.values()) |
| 239 | |
| 240 | return { |
| 241 | "note": note_path, |
| 242 | "direction": "forward" if forward else "reverse", |
| 243 | "depth_limit": depth, |
| 244 | "by_depth": serialised, |
| 245 | "total": total, |
| 246 | "include_attachments": include_attachments, |
| 247 | } |
| 248 | |
| 249 | |
| 250 | # --------------------------------------------------------------------------- |
| 251 | # Convenience: build + query in one call (used by CLI when no cache exists) |
| 252 | # --------------------------------------------------------------------------- |
| 253 | |
| 254 | |
| 255 | def deps_for_note( |
| 256 | root: pathlib.Path, |
| 257 | note_path: str, |
| 258 | *, |
| 259 | reverse: bool = False, |
| 260 | include_broken: bool = False, |
| 261 | ) -> dict[str, Any]: |
| 262 | """Build a link index and return :func:`note_deps` for *note_path*. |
| 263 | |
| 264 | Convenience wrapper for one-shot CLI invocations. For repeated queries |
| 265 | against the same vault, build the :class:`LinkIndex` once and call |
| 266 | :func:`note_deps` directly. |
| 267 | |
| 268 | Args: |
| 269 | root: Vault root. |
| 270 | note_path: Note to query. |
| 271 | reverse: Reverse-edge query. |
| 272 | include_broken: Include broken links. |
| 273 | |
| 274 | Returns: |
| 275 | JSON-shaped dict (see :func:`note_deps`). |
| 276 | """ |
| 277 | index = build_link_index(root) |
| 278 | return note_deps( |
| 279 | index, |
| 280 | note_path, |
| 281 | reverse=reverse, |
| 282 | include_broken=include_broken, |
| 283 | ) |
| 284 | |
| 285 | |
| 286 | def impact_for_note( |
| 287 | root: pathlib.Path, |
| 288 | note_path: str, |
| 289 | *, |
| 290 | forward: bool = False, |
| 291 | depth: int = 0, |
| 292 | include_attachments: bool = True, |
| 293 | ) -> dict[str, Any]: |
| 294 | """Build a link index and return :func:`note_impact` for *note_path*. |
| 295 | |
| 296 | Args: |
| 297 | root: Vault root. |
| 298 | note_path: Note to query. |
| 299 | forward: Forward (callees) instead of reverse (callers). |
| 300 | depth: BFS depth cap (``0`` = unlimited). |
| 301 | include_attachments: Honour ``include_attachments`` in the result. |
| 302 | |
| 303 | Returns: |
| 304 | JSON-shaped dict (see :func:`note_impact`). |
| 305 | """ |
| 306 | index = build_link_index(root) |
| 307 | return note_impact( |
| 308 | index, |
| 309 | note_path, |
| 310 | forward=forward, |
| 311 | depth=depth, |
| 312 | include_attachments=include_attachments, |
| 313 | ) |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
33 days ago