documate 0.2.0__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.
documate/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """documate — generate docs from your code and keep them honest.
2
+
3
+ One command: bare `documate` writes the documentation (structure from the code
4
+ graph, prose from your docstrings) and then gates it; `documate --check` runs the
5
+ gate alone for CI — failing when the docs go stale, name dead code, or lie about
6
+ code that changed. Repo-agnostic: plugs into any codebase via
7
+ an optional documate.config.json.
8
+
9
+ Public surface is the CLI; the modules (core, docs, check, anchors, resolve, drift,
10
+ extract, graphdb, config) are importable for embedding.
11
+ """
12
+
13
+ __version__ = "0.2.0"
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tirth Kanani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ # Origin
2
+
3
+ This package began as a copy of the indexer core from **code-review-graph**
4
+ (`github.com/tirth8205/code-review-graph`, v2.3.6, commit `b72413c`), licensed
5
+ MIT — Copyright (c) 2026 Tirth Kanani. MIT requires that copyright and
6
+ permission notice to stay with the code, so the `LICENSE` file in this
7
+ directory is permanent even as the code diverges.
8
+
9
+ Since July 2026 this is **first-party documate code**: no upstream is tracked,
10
+ there is no re-pull story, edit it like any other module. Two rules survive
11
+ from the old boundary because they're good architecture, not because the code
12
+ is foreign:
13
+
14
+ - `graphdb.py` stays the only importer — the engine API and its sqlite schema
15
+ are referenced nowhere else, so engine refactors never leak.
16
+ - The engine ships no tests of its own; documate's suite (`RealGraph`-style
17
+ fixtures in `tests/test_documate.py`) is the safety net — cover engine
18
+ changes there.
@@ -0,0 +1 @@
1
+ """Code indexing engine (tree-sitter → sqlite graph). First-party; ORIGIN.md has provenance + license."""
@@ -0,0 +1,409 @@
1
+ """Change impact analysis for code review.
2
+
3
+ Maps git/svn diffs to affected functions, flows, communities, and test coverage
4
+ gaps. Produces risk-scored, priority-ordered review guidance.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import re
12
+ import subprocess
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .constants import SECURITY_KEYWORDS as _SECURITY_KEYWORDS
17
+ from .flows import get_affected_flows
18
+ from .graph import GraphNode, GraphStore, _sanitize_name, node_to_dict
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _GIT_TIMEOUT = int(os.environ.get("CRG_GIT_TIMEOUT", "30")) # seconds, configurable
23
+
24
+ _SAFE_GIT_REF = re.compile(r"^[A-Za-z0-9_.~^/@{}\-]+$")
25
+ _SAFE_SVN_REV = re.compile(r"^r?\d+(:r?\d+|:HEAD|:BASE|:COMMITTED)?$", re.IGNORECASE)
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # 1. parse_git_diff_ranges / parse_svn_diff_ranges
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ def parse_git_diff_ranges(
34
+ repo_root: str,
35
+ base: str = "HEAD~1",
36
+ ) -> dict[str, list[tuple[int, int]]]:
37
+ """Run ``git diff --unified=0`` and extract changed line ranges per file.
38
+
39
+ Args:
40
+ repo_root: Absolute path to the repository root.
41
+ base: Git ref to diff against (default: ``HEAD~1``).
42
+
43
+ Returns:
44
+ Mapping of file paths to lists of ``(start_line, end_line)`` tuples.
45
+ Returns an empty dict on error.
46
+ """
47
+ if not _SAFE_GIT_REF.match(base):
48
+ logger.warning("Invalid git ref rejected: %s", base)
49
+ return {}
50
+ try:
51
+ result = subprocess.run(
52
+ ["git", "diff", "--unified=0", base, "--"],
53
+ capture_output=True,
54
+ stdin=subprocess.DEVNULL,
55
+ text=True,
56
+ encoding="utf-8",
57
+ errors="replace",
58
+ cwd=repo_root,
59
+ timeout=_GIT_TIMEOUT,
60
+ )
61
+ if result.returncode != 0:
62
+ logger.warning("git diff failed (rc=%d): %s", result.returncode, result.stderr[:200])
63
+ return {}
64
+ except (OSError, subprocess.SubprocessError) as exc:
65
+ logger.warning("git diff error: %s", exc)
66
+ return {}
67
+
68
+ return _parse_unified_diff(result.stdout)
69
+
70
+
71
+ def parse_svn_diff_ranges(
72
+ repo_root: str,
73
+ rev_range: str | None = None,
74
+ ) -> dict[str, list[tuple[int, int]]]:
75
+ """Run ``svn diff`` and extract changed line ranges per file.
76
+
77
+ Args:
78
+ repo_root: Absolute path to the SVN working copy root.
79
+ rev_range: Optional SVN revision range in ``rXXX:HEAD`` format.
80
+ When *None*, diffs the working copy against BASE (local changes).
81
+
82
+ Returns:
83
+ Mapping of file paths to lists of ``(start_line, end_line)`` tuples.
84
+ Returns an empty dict on error.
85
+ """
86
+ cmd = ["svn", "diff", "--non-interactive"]
87
+ if rev_range:
88
+ if not _SAFE_SVN_REV.match(rev_range):
89
+ logger.warning("Invalid SVN revision range rejected: %s", rev_range)
90
+ return {}
91
+ cmd.extend(["-r", rev_range])
92
+ try:
93
+ result = subprocess.run(
94
+ cmd,
95
+ capture_output=True,
96
+ stdin=subprocess.DEVNULL,
97
+ text=True,
98
+ encoding="utf-8",
99
+ errors="replace",
100
+ cwd=repo_root,
101
+ timeout=_GIT_TIMEOUT,
102
+ )
103
+ if result.returncode != 0:
104
+ logger.warning("svn diff failed (rc=%d): %s", result.returncode, result.stderr[:200])
105
+ return {}
106
+ except (OSError, subprocess.SubprocessError) as exc:
107
+ logger.warning("svn diff error: %s", exc)
108
+ return {}
109
+
110
+ return _parse_unified_diff(result.stdout)
111
+
112
+
113
+ def parse_diff_ranges(
114
+ repo_root: str,
115
+ base: str = "HEAD~1",
116
+ ) -> dict[str, list[tuple[int, int]]]:
117
+ """Auto-detect VCS and return changed line ranges per file.
118
+
119
+ Dispatches to :func:`parse_git_diff_ranges` for Git repositories and
120
+ :func:`parse_svn_diff_ranges` for SVN working copies.
121
+
122
+ Args:
123
+ repo_root: Absolute path to the repository/working-copy root.
124
+ base: For Git: the ref to diff against (default ``HEAD~1``).
125
+ For SVN: an optional revision range (e.g. ``"r100:HEAD"``);
126
+ when *base* is not a valid SVN revision, working-copy changes
127
+ (``svn diff``) are used instead.
128
+ """
129
+ root_path = Path(repo_root)
130
+ if (root_path / ".svn").exists():
131
+ rev_range = base if _SAFE_SVN_REV.match(base) else None
132
+ return parse_svn_diff_ranges(repo_root, rev_range)
133
+ return parse_git_diff_ranges(repo_root, base)
134
+
135
+
136
+ def _parse_unified_diff(diff_text: str) -> dict[str, list[tuple[int, int]]]:
137
+ """Parse unified diff output into file -> line-range mappings.
138
+
139
+ Handles the ``@@ -old,count +new,count @@`` hunk header format.
140
+ """
141
+ ranges: dict[str, list[tuple[int, int]]] = {}
142
+ current_file: str | None = None
143
+
144
+ # Match "+++ b/path/to/file"
145
+ file_pattern = re.compile(r"^\+\+\+ b/(.+)$")
146
+ # Match "@@ ... +start,count @@" or "@@ ... +start @@"
147
+ hunk_pattern = re.compile(r"^@@ .+? \+(\d+)(?:,(\d+))? @@")
148
+
149
+ for line in diff_text.splitlines():
150
+ file_match = file_pattern.match(line)
151
+ if file_match:
152
+ current_file = file_match.group(1)
153
+ continue
154
+
155
+ hunk_match = hunk_pattern.match(line)
156
+ if hunk_match and current_file is not None:
157
+ start = int(hunk_match.group(1))
158
+ count = int(hunk_match.group(2)) if hunk_match.group(2) else 1
159
+ if count == 0:
160
+ # Pure deletion hunk (no lines added); still note the position.
161
+ end = start
162
+ else:
163
+ end = start + count - 1
164
+ ranges.setdefault(current_file, []).append((start, end))
165
+
166
+ return ranges
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # 2. map_changes_to_nodes
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def map_changes_to_nodes(
175
+ store: GraphStore,
176
+ changed_ranges: dict[str, list[tuple[int, int]]],
177
+ ) -> list[GraphNode]:
178
+ """Find graph nodes whose line ranges overlap the changed lines.
179
+
180
+ Args:
181
+ store: The graph store.
182
+ changed_ranges: Mapping of file paths to ``(start, end)`` tuples.
183
+
184
+ Returns:
185
+ Deduplicated list of overlapping graph nodes.
186
+ """
187
+ seen: set[str] = set()
188
+ result: list[GraphNode] = []
189
+
190
+ for file_path, ranges in changed_ranges.items():
191
+ # Try the path as-is, then also try all nodes to match relative paths.
192
+ nodes = store.get_nodes_by_file(file_path)
193
+ if not nodes:
194
+ # The graph may store absolute paths; try a suffix match.
195
+ matched_paths = store.get_files_matching(file_path)
196
+ for mp in matched_paths:
197
+ nodes.extend(store.get_nodes_by_file(mp))
198
+
199
+ for node in nodes:
200
+ if node.qualified_name in seen:
201
+ continue
202
+ if node.line_start is None or node.line_end is None:
203
+ continue
204
+ # Check overlap with any changed range.
205
+ for start, end in ranges:
206
+ if node.line_start <= end and node.line_end >= start:
207
+ result.append(node)
208
+ seen.add(node.qualified_name)
209
+ break
210
+
211
+ return result
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # 3. compute_risk_score
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def compute_risk_score(store: GraphStore, node: GraphNode) -> float:
220
+ """Compute a risk score (0.0 - 1.0) for a single node.
221
+
222
+ Scoring factors:
223
+ - Flow participation: 0.05 per flow membership, capped at 0.25
224
+ - Community crossing: 0.05 per caller from a different community, capped at 0.15
225
+ - Test coverage: 0.30 (untested) scaling down to 0.05 (5+ TESTED_BY edges)
226
+ - Security sensitivity: 0.20 if name matches security keywords
227
+ - Caller count: callers / 20, capped at 0.10
228
+ """
229
+ score = 0.0
230
+
231
+ # --- Flow participation (cap 0.25), weighted by criticality ---
232
+ flow_criticalities = store.get_flow_criticalities_for_node(node.id)
233
+ if flow_criticalities:
234
+ score += min(sum(flow_criticalities), 0.25)
235
+ else:
236
+ flow_count = store.count_flow_memberships(node.id)
237
+ score += min(flow_count * 0.05, 0.25)
238
+
239
+ # --- Community crossing (cap 0.15) ---
240
+ callers = store.get_edges_by_target(node.qualified_name)
241
+ caller_edges = [e for e in callers if e.kind == "CALLS"]
242
+
243
+ cross_community = 0
244
+ node_cid = store.get_node_community_id(node.id)
245
+
246
+ if node_cid is not None and caller_edges:
247
+ caller_qns = [edge.source_qualified for edge in caller_edges]
248
+ cid_map = store.get_community_ids_by_qualified_names(caller_qns)
249
+ for cid in cid_map.values():
250
+ if cid is not None and cid != node_cid:
251
+ cross_community += 1
252
+ score += min(cross_community * 0.05, 0.15)
253
+
254
+ # --- Test coverage (direct + transitive) ---
255
+ transitive_tests = store.get_transitive_tests(node.qualified_name)
256
+ test_count = len(transitive_tests)
257
+ score += 0.30 - (min(test_count / 5.0, 1.0) * 0.25)
258
+
259
+ # --- Security sensitivity ---
260
+ name_lower = node.name.lower()
261
+ qn_lower = node.qualified_name.lower()
262
+ if any(kw in name_lower or kw in qn_lower for kw in _SECURITY_KEYWORDS):
263
+ score += 0.20
264
+
265
+ # --- Caller count (cap 0.10) ---
266
+ caller_count = len(caller_edges)
267
+ score += min(caller_count / 20.0, 0.10)
268
+
269
+ return round(min(max(score, 0.0), 1.0), 4)
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # 4. analyze_changes
274
+ # ---------------------------------------------------------------------------
275
+
276
+
277
+ def analyze_changes(
278
+ store: GraphStore,
279
+ changed_files: list[str],
280
+ changed_ranges: dict[str, list[tuple[int, int]]] | None = None,
281
+ repo_root: str | None = None,
282
+ base: str = "HEAD~1",
283
+ ) -> dict[str, Any]:
284
+ """Analyze changes and produce risk-scored review guidance.
285
+
286
+ Args:
287
+ store: The graph store.
288
+ changed_files: List of changed file paths.
289
+ changed_ranges: Optional pre-parsed diff ranges. If not provided and
290
+ ``repo_root`` is given, they are computed via the detected VCS
291
+ (Git or SVN).
292
+ repo_root: Repository root (for git/svn diff).
293
+ base: Git ref or SVN revision range to diff against.
294
+
295
+ Returns:
296
+ Dict with ``summary``, ``risk_score``, ``changed_functions``,
297
+ ``affected_flows``, ``test_gaps``, and ``review_priorities``.
298
+ """
299
+ # Compute changed ranges if not provided.
300
+ if changed_ranges is None and repo_root is not None:
301
+ # Diff keys are forward-slash paths relative to the repo root, but
302
+ # the graph stores absolute native paths. Remap so lookups work on
303
+ # Windows, where the LIKE-suffix fallback cannot bridge
304
+ # "src/app.py" to "C:\repo\src\app.py" (#528). Keys that are
305
+ # already absolute pass through pathlib joining unchanged. The
306
+ # explicit changed_ranges path (MCP) is untouched — tools/review.py
307
+ # remaps before calling, and remapping twice would corrupt keys.
308
+ root_path = Path(repo_root)
309
+ changed_ranges = {
310
+ str(root_path / key): ranges
311
+ for key, ranges in parse_diff_ranges(repo_root, base).items()
312
+ }
313
+
314
+ # Map changes to nodes.
315
+ if changed_ranges:
316
+ changed_nodes = map_changes_to_nodes(store, changed_ranges)
317
+ else:
318
+ # Fallback: all nodes in changed files.
319
+ changed_nodes = []
320
+ for fp in changed_files:
321
+ changed_nodes.extend(store.get_nodes_by_file(fp))
322
+
323
+ # Filter to functions/tests for risk scoring (skip File nodes).
324
+ changed_funcs = [
325
+ n for n in changed_nodes
326
+ if n.kind in ("Function", "Test", "Class")
327
+ ]
328
+
329
+ # Cap to prevent O(N*M) query explosion on large PRs.
330
+ _max_funcs = int(os.environ.get("CRG_MAX_CHANGED_FUNCS", "500"))
331
+ funcs_truncated = len(changed_funcs) > _max_funcs
332
+ if funcs_truncated:
333
+ changed_funcs = changed_funcs[:_max_funcs]
334
+
335
+ # Compute per-node risk scores.
336
+ node_risks: list[dict[str, Any]] = []
337
+ for node in changed_funcs:
338
+ risk = compute_risk_score(store, node)
339
+ node_risks.append({
340
+ **node_to_dict(node),
341
+ "risk_score": risk,
342
+ })
343
+
344
+ # Overall risk score: max of individual risks, or 0.
345
+ overall_risk = max((nr["risk_score"] for nr in node_risks), default=0.0)
346
+
347
+ # Affected flows.
348
+ affected = get_affected_flows(store, changed_files)
349
+
350
+ # Detect test gaps: changed functions without TESTED_BY edges.
351
+ test_gaps: list[dict[str, Any]] = []
352
+ for node in changed_funcs:
353
+ if node.is_test:
354
+ continue
355
+ tested = store.get_edges_by_target(node.qualified_name)
356
+ if not any(e.kind == "TESTED_BY" for e in tested):
357
+ test_gaps.append({
358
+ "name": _sanitize_name(node.name),
359
+ "qualified_name": _sanitize_name(node.qualified_name),
360
+ "file": node.file_path,
361
+ "line_start": node.line_start,
362
+ "line_end": node.line_end,
363
+ })
364
+
365
+ # Review priorities: top 10 by risk score.
366
+ review_priorities = sorted(node_risks, key=lambda x: x["risk_score"], reverse=True)[:10]
367
+
368
+ # Build summary.
369
+ summary_parts = [
370
+ f"Analyzed {len(changed_files)} changed file(s):",
371
+ f" - {len(changed_funcs)} changed function(s)/class(es)",
372
+ f" - {affected['total']} affected flow(s)",
373
+ f" - {len(test_gaps)} test gap(s)",
374
+ f" - Overall risk score: {overall_risk:.2f}",
375
+ ]
376
+ if test_gaps:
377
+ # Dedup by bare name in the human summary. The underlying test_gaps
378
+ # list keeps every entry (a downstream consumer needs precision via
379
+ # qualified_name), but a graph that ended up with the same function
380
+ # stored under two qualified_names (e.g. relative + absolute path
381
+ # variants) would otherwise print "X, X, Y, Y" — surfacing graph
382
+ # corruption as a UX bug. The root cause is path normalization;
383
+ # this is the defensive last line.
384
+ seen_names: set[str] = set()
385
+ gap_names: list[str] = []
386
+ for g in test_gaps:
387
+ n = g["name"]
388
+ if n in seen_names:
389
+ continue
390
+ seen_names.add(n)
391
+ gap_names.append(n)
392
+ if len(gap_names) >= 5:
393
+ break
394
+ summary_parts.append(f" - Untested: {', '.join(gap_names)}")
395
+ if funcs_truncated:
396
+ summary_parts.append(
397
+ f" - Warning: analysis capped at {_max_funcs} functions "
398
+ f"(set CRG_MAX_CHANGED_FUNCS to adjust)"
399
+ )
400
+
401
+ return {
402
+ "summary": "\n".join(summary_parts),
403
+ "risk_score": overall_risk,
404
+ "changed_functions": node_risks,
405
+ "affected_flows": affected["affected_flows"],
406
+ "test_gaps": test_gaps,
407
+ "review_priorities": review_priorities,
408
+ "functions_truncated": funcs_truncated,
409
+ }
@@ -0,0 +1,23 @@
1
+ """Shared constants for code-review-graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ SECURITY_KEYWORDS: frozenset[str] = frozenset({
8
+ "auth", "login", "password", "token", "session", "crypt", "secret",
9
+ "credential", "permission", "sql", "query", "execute", "connect",
10
+ "socket", "request", "http", "sanitize", "validate", "encrypt",
11
+ "decrypt", "hash", "sign", "verify", "admin", "privilege",
12
+ })
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Configurable limits (override via environment variables)
16
+ # ---------------------------------------------------------------------------
17
+ MAX_IMPACT_NODES = int(os.environ.get("CRG_MAX_IMPACT_NODES", "500"))
18
+ MAX_IMPACT_DEPTH = int(os.environ.get("CRG_MAX_IMPACT_DEPTH", "2"))
19
+ MAX_BFS_DEPTH = int(os.environ.get("CRG_MAX_BFS_DEPTH", "15"))
20
+ MAX_SEARCH_RESULTS = int(os.environ.get("CRG_MAX_SEARCH_RESULTS", "20"))
21
+
22
+ # BFS engine: "sql" (SQLite recursive CTE) or "networkx" (Python-side BFS)
23
+ BFS_ENGINE = os.environ.get("CRG_BFS_ENGINE", "sql")