code_intel.py python
313 lines 10.6 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 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 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago