docs-drift 0.1.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.
Files changed (131) hide show
  1. docs_drift/__init__.py +7 -0
  2. docs_drift/analysis.py +52 -0
  3. docs_drift/baseline.py +70 -0
  4. docs_drift/cli.py +871 -0
  5. docs_drift/collaboration.py +282 -0
  6. docs_drift/config.py +113 -0
  7. docs_drift/docs/__init__.py +1 -0
  8. docs_drift/docs/defaults.py +250 -0
  9. docs_drift/docs/discover.py +172 -0
  10. docs_drift/docs/extract_blocks.py +596 -0
  11. docs_drift/docs/extract_inline.py +561 -0
  12. docs_drift/docs/pipeline.py +234 -0
  13. docs_drift/docs/preprocess.py +71 -0
  14. docs_drift/docs/sections.py +247 -0
  15. docs_drift/errors.py +119 -0
  16. docs_drift/fix/__init__.py +1 -0
  17. docs_drift/fix/edits.py +124 -0
  18. docs_drift/fix/model.py +139 -0
  19. docs_drift/fix/rules.py +232 -0
  20. docs_drift/git_safety.py +35 -0
  21. docs_drift/graph/queries/evidence_path.cypher +6 -0
  22. docs_drift/history.py +412 -0
  23. docs_drift/history_cache.py +62 -0
  24. docs_drift/ids.py +50 -0
  25. docs_drift/index/__init__.py +211 -0
  26. docs_drift/index/base.py +239 -0
  27. docs_drift/index/config.py +185 -0
  28. docs_drift/index/js_cli.py +211 -0
  29. docs_drift/index/js_routes.py +68 -0
  30. docs_drift/index/js_symbols.py +219 -0
  31. docs_drift/index/python_cli.py +370 -0
  32. docs_drift/index/python_routes.py +102 -0
  33. docs_drift/index/python_source.py +20 -0
  34. docs_drift/index/python_symbols.py +335 -0
  35. docs_drift/index/queries/javascript.scm +4 -0
  36. docs_drift/index/queries/typescript.scm +4 -0
  37. docs_drift/index/routes_common.py +16 -0
  38. docs_drift/index/tasks.py +131 -0
  39. docs_drift/index/ts_parser.py +20 -0
  40. docs_drift/limits.py +18 -0
  41. docs_drift/llm/__init__.py +7 -0
  42. docs_drift/llm/candidates.py +81 -0
  43. docs_drift/llm/client.py +185 -0
  44. docs_drift/llm/pricing.py +54 -0
  45. docs_drift/llm/privacy.py +63 -0
  46. docs_drift/llm/prompts/rewrite_v1.md +9 -0
  47. docs_drift/llm/prompts/verify_v1.md +12 -0
  48. docs_drift/llm/prompts.py +28 -0
  49. docs_drift/llm/retrieve.py +60 -0
  50. docs_drift/llm/types.py +68 -0
  51. docs_drift/llm/verify.py +179 -0
  52. docs_drift/lsp.py +199 -0
  53. docs_drift/match/__init__.py +5 -0
  54. docs_drift/match/commands.py +201 -0
  55. docs_drift/match/defaults.py +79 -0
  56. docs_drift/match/engine.py +319 -0
  57. docs_drift/match/rename.py +69 -0
  58. docs_drift/models.py +249 -0
  59. docs_drift/ownership.py +70 -0
  60. docs_drift/policy.py +81 -0
  61. docs_drift/portfolio.py +147 -0
  62. docs_drift/py.typed +0 -0
  63. docs_drift/report/__init__.py +38 -0
  64. docs_drift/report/common.py +31 -0
  65. docs_drift/report/github.py +22 -0
  66. docs_drift/report/html.py +106 -0
  67. docs_drift/report/json.py +17 -0
  68. docs_drift/report/junit.py +30 -0
  69. docs_drift/report/markdown.py +58 -0
  70. docs_drift/report/partials/body.html +19 -0
  71. docs_drift/report/partials/report.css +12 -0
  72. docs_drift/report/partials/script.js +95 -0
  73. docs_drift/report/portfolio.html +14 -0
  74. docs_drift/report/report.html +8 -0
  75. docs_drift/report/sarif.py +68 -0
  76. docs_drift/report/terminal.py +90 -0
  77. docs_drift/report/vendor/CYTOSCAPE-LICENSE +19 -0
  78. docs_drift/report/vendor/cytoscape.min.js +32 -0
  79. docs_drift/report/workflow.html +12 -0
  80. docs_drift/review.py +113 -0
  81. docs_drift/safe_files.py +19 -0
  82. docs_drift/scanning.py +202 -0
  83. docs_drift/schemas/SARIF-LICENSE.md +7 -0
  84. docs_drift/schemas/SARIF-NOTICES.txt +3 -0
  85. docs_drift/schemas/sarif-schema-2.1.0.json +3389 -0
  86. docs_drift/schemas/scan-result.v1.json +1024 -0
  87. docs_drift/setup.py +130 -0
  88. docs_drift/snapshots.py +100 -0
  89. docs_drift/support.py +31 -0
  90. docs_drift/watch.py +157 -0
  91. docs_drift/web/__init__.py +1 -0
  92. docs_drift/web/__main__.py +23 -0
  93. docs_drift/web/app.py +289 -0
  94. docs_drift/web/cleanup.py +23 -0
  95. docs_drift/web/examples/clean-repo.json +646 -0
  96. docs_drift/web/examples/fastapi-svc.json +723 -0
  97. docs_drift/web/examples/files-to-prompt.json +3366 -0
  98. docs_drift/web/examples/judge-fixture.json +226 -0
  99. docs_drift/web/examples/manifest.json +89 -0
  100. docs_drift/web/examples/node-tool.json +823 -0
  101. docs_drift/web/examples/py-cli.json +1267 -0
  102. docs_drift/web/examples/rich-cli-reviewed.json +7855 -0
  103. docs_drift/web/examples/rich-cli.json +8010 -0
  104. docs_drift/web/examples/verification.json +16 -0
  105. docs_drift/web/git.py +274 -0
  106. docs_drift/web/jobs.py +352 -0
  107. docs_drift/web/settings.py +50 -0
  108. docs_drift/web/static/demo.css +37 -0
  109. docs_drift/web/static/demo.js +184 -0
  110. docs_drift/web/static/evidence/correction-verification.json +28 -0
  111. docs_drift/web/static/evidence/flow-highlights-2026-10-22.png +0 -0
  112. docs_drift/web/static/evidence/fresh-scan.json +7855 -0
  113. docs_drift/web/static/evidence/review.json +44 -0
  114. docs_drift/web/static/evidence/rich-cli-correction.patch +20 -0
  115. docs_drift/web/static/evidence/runtime.json +26 -0
  116. docs_drift/web/static/evidence/saved-scan.json +8010 -0
  117. docs_drift/web/static/evidence/usage-card-supplied.png +0 -0
  118. docs_drift/web/templates/base.html +34 -0
  119. docs_drift/web/templates/case.html +13 -0
  120. docs_drift/web/templates/landing.html +90 -0
  121. docs_drift/web/templates/scan.html +2 -0
  122. docs_drift/web/templates/voice.html +1 -0
  123. docs_drift/web/validation.py +47 -0
  124. docs_drift/web/worker.py +152 -0
  125. docs_drift/workflow_cli.py +96 -0
  126. docs_drift/workspaces.py +128 -0
  127. docs_drift-0.1.0.dist-info/METADATA +331 -0
  128. docs_drift-0.1.0.dist-info/RECORD +131 -0
  129. docs_drift-0.1.0.dist-info/WHEEL +4 -0
  130. docs_drift-0.1.0.dist-info/entry_points.txt +2 -0
  131. docs_drift-0.1.0.dist-info/licenses/LICENSE +21 -0
docs_drift/cli.py ADDED
@@ -0,0 +1,871 @@
1
+ """Consistent command-line entry points for scanning, reporting and fixes."""
2
+
3
+ import asyncio
4
+ import json
5
+ import math
6
+ import sys
7
+ import time
8
+ import tomllib
9
+ from dataclasses import dataclass, replace
10
+ from enum import StrEnum
11
+ from io import TextIOWrapper
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ import tomli_w
16
+ import typer
17
+ from rapidfuzz import process
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.table import Table
21
+ from rich.tree import Tree
22
+
23
+ from docs_drift import __version__
24
+ from docs_drift.baseline import load_baseline, new_findings, save_baseline
25
+ from docs_drift.config import Config, ConfigError, HistoryConfig, load_config
26
+ from docs_drift.docs.defaults import normalize_value
27
+ from docs_drift.docs.pipeline import scan_claims
28
+ from docs_drift.errors import Command, Group
29
+ from docs_drift.fix.edits import StaleDocumentError, apply_file, unified_patch
30
+ from docs_drift.fix.model import add_model_fixes
31
+ from docs_drift.fix.rules import plan_rule_fixes
32
+ from docs_drift.history import GitHistory
33
+ from docs_drift.index import build_index, clear_cache
34
+ from docs_drift.index.base import CodeIndex
35
+ from docs_drift.llm.client import ClientError
36
+ from docs_drift.llm.pricing import format_usage
37
+ from docs_drift.llm.verify import verify_prose
38
+ from docs_drift.match import match_claims
39
+ from docs_drift.models import Claim, ClaimKind, Evidence, Finding, ScanResult, SymbolKind, Verdict
40
+ from docs_drift.report import RenderOptions, render
41
+
42
+ app = typer.Typer(
43
+ help="Find drift between repository documentation and code.", cls=Group, pretty_exceptions_enable=False
44
+ )
45
+ cache_app = typer.Typer(help="Manage local index cache.", cls=Group, add_completion=False)
46
+ baseline_app = typer.Typer(help="Manage the finding baseline.", cls=Group, no_args_is_help=True)
47
+ _GENERIC_ENV_TOKENS = {"app", "env", "var", "api", "service"}
48
+
49
+
50
+ def _same_env_concept(old: str, new: str) -> bool:
51
+ """Require a shared specific token; generic prefixes alone do not prove a rename."""
52
+ old_parts = {part.casefold() for part in old.split("_")} - _GENERIC_ENV_TOKENS
53
+ new_parts = {part.casefold() for part in new.split("_")} - _GENERIC_ENV_TOKENS
54
+ return bool(old_parts & new_parts)
55
+
56
+
57
+ class OutputFormat(StrEnum):
58
+ """Supported report formats."""
59
+
60
+ terminal = "terminal"
61
+ json = "json"
62
+ markdown = "markdown"
63
+ sarif = "sarif"
64
+ html = "html"
65
+ junit = "junit"
66
+ github = "github"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class CLIOptions:
71
+ """Per-invocation state, carried by Typer rather than global variables."""
72
+
73
+ repo: Path
74
+ config: Config
75
+ output_format: OutputFormat
76
+ verbose: bool
77
+ no_color: bool
78
+ quiet: bool = False
79
+ output: Path | None = None
80
+ output_json: Path | None = None
81
+ output_sarif: Path | None = None
82
+ show_authors: bool = False
83
+
84
+
85
+ def _version(value: bool) -> None:
86
+ if value:
87
+ typer.echo(__version__)
88
+ raise typer.Exit()
89
+
90
+
91
+ @app.callback(invoke_without_command=True)
92
+ def main(
93
+ ctx: typer.Context,
94
+ repo: Annotated[Path, typer.Option(help="Repository to inspect.")] = Path("."),
95
+ config: Annotated[Path | None, typer.Option(help="Explicit TOML configuration file.")] = None,
96
+ output_format: Annotated[
97
+ OutputFormat,
98
+ typer.Option(
99
+ "--format",
100
+ metavar="FORMAT",
101
+ help="Report format: terminal, json, markdown, sarif, html, junit, or github.",
102
+ ),
103
+ ] = OutputFormat.terminal,
104
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show detailed diagnostics.")] = False,
105
+ no_color: Annotated[bool, typer.Option("--no-color", help="Disable colored output.")] = False,
106
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress routine stdout output.")] = False,
107
+ debug: Annotated[bool, typer.Option("--debug", help="Show the traceback when an operation fails.")] = False,
108
+ output: Annotated[Path | None, typer.Option(help="Write the selected report to this file.")] = None,
109
+ output_json: Annotated[Path | None, typer.Option(help="Also write the full JSON report.")] = None,
110
+ output_sarif: Annotated[Path | None, typer.Option(help="Also write a SARIF report.")] = None,
111
+ show_authors: Annotated[bool, typer.Option(help="Include author names in HTML reports.")] = False,
112
+ version: Annotated[
113
+ bool, typer.Option("--version", callback=_version, is_eager=True, help="Print the version.")
114
+ ] = False,
115
+ ) -> None:
116
+ """Read global options and configuration before dispatching a command."""
117
+ if isinstance(sys.stdout, TextIOWrapper) and not sys.stdout.isatty():
118
+ sys.stdout.reconfigure(encoding="utf-8")
119
+ if no_color:
120
+ ctx.color = False
121
+ if ctx.invoked_subcommand is None:
122
+ typer.echo(ctx.get_help(), color=not no_color)
123
+ return
124
+ destinations = [path.resolve() for path in (output, output_json, output_sarif) if path is not None]
125
+ if len(set(destinations)) != len(destinations):
126
+ raise typer.BadParameter("Choose a different path for each report destination.", param_hint="--output")
127
+ root = repo.resolve()
128
+ if not root.is_dir():
129
+ raise typer.BadParameter("Choose an existing repository directory.", param_hint="--repo")
130
+ try:
131
+ settings = Config() if ctx.meta.get("help_only") else load_config(root, config)
132
+ except ConfigError as exc:
133
+ raise typer.BadParameter(str(exc), param_hint="--config") from exc
134
+ ctx.obj = CLIOptions(
135
+ root, settings, output_format, verbose, no_color, quiet, output, output_json, output_sarif, show_authors
136
+ )
137
+
138
+
139
+ def _model_layer(result: ScanResult, enabled: bool, budget: float | None) -> ScanResult:
140
+ config = result.config_used
141
+ if not enabled and not config.llm.enabled:
142
+ return result
143
+ if budget is not None and (not math.isfinite(budget) or budget < 0):
144
+ raise typer.BadParameter("Use a finite, non-negative USD amount.", param_hint="--llm-budget")
145
+ llm_config = config.llm.model_copy(update={"enabled": True, **({"budget": budget} if budget is not None else {})})
146
+ result = result.model_copy(update={"config_used": config.model_copy(update={"llm": llm_config})})
147
+ marker = result.repo_path / ".docs-drift" / "llm-privacy-v1"
148
+ if not marker.exists():
149
+ typer.echo(
150
+ "Model privacy notice: live/record mode sends selected doc sentences and up to 6 redacted source "
151
+ "snippets of 60 lines each through OpenRouter to the configured model provider. Replay stays local. "
152
+ "Recordings store request hashes and validated responses. See docs/SECURITY.md.",
153
+ err=True,
154
+ )
155
+ try:
156
+ marker.parent.mkdir(parents=True, exist_ok=True)
157
+ marker.write_text("notice-v1\n", encoding="utf-8")
158
+ except OSError:
159
+ pass
160
+ try:
161
+ return asyncio.run(verify_prose(result))
162
+ except (ClientError, ValueError) as exc:
163
+ raise typer.BadParameter(str(exc), param_hint="--llm") from exc
164
+
165
+
166
+ def _full_findings(
167
+ claims: list[Claim],
168
+ index: CodeIndex,
169
+ repo: Path,
170
+ *,
171
+ initial: list[Finding] | None = None,
172
+ history_config: HistoryConfig | None = None,
173
+ ) -> tuple[list[Finding], list[str]]:
174
+ """Match claims and enrich drift findings with text-only Git history evidence."""
175
+ findings = match_claims(claims, index, repo) if initial is None else initial
176
+ history = GitHistory(repo, history_config)
177
+ if any(f.is_drift or f.verdict == Verdict.unverifiable for f in findings):
178
+ history.prepare()
179
+ enriched: list[Finding] = []
180
+ history_kinds = {
181
+ ClaimKind.cli_flag: SymbolKind.cli_flag,
182
+ ClaimKind.cli_command: SymbolKind.cli_command,
183
+ ClaimKind.env_var: SymbolKind.env_var,
184
+ ClaimKind.endpoint: SymbolKind.route,
185
+ ClaimKind.config_key: SymbolKind.config_key,
186
+ ClaimKind.package_script: SymbolKind.package_script,
187
+ ClaimKind.make_target: SymbolKind.make_target,
188
+ }
189
+ for original in findings:
190
+ default_without_subject = (
191
+ original.claim.kind == ClaimKind.default_value
192
+ and original.verdict == Verdict.unverifiable
193
+ and not any(
194
+ symbol.name == original.claim.value or original.claim.value in symbol.aliases
195
+ for symbol in index.symbols
196
+ )
197
+ )
198
+ if not original.is_drift and not default_without_subject:
199
+ enriched.append(original)
200
+ continue
201
+ current = original
202
+ if current.verdict == Verdict.default_changed:
203
+ commit, excerpt = history.default_change(current)
204
+ if commit:
205
+ current = current.model_copy(
206
+ update={
207
+ "introduced_by": commit,
208
+ "evidence": [
209
+ *current.evidence,
210
+ Evidence(
211
+ file=current.matched_symbol.file if current.matched_symbol else current.claim.doc_path,
212
+ line=current.matched_symbol.line if current.matched_symbol else 1,
213
+ text=excerpt,
214
+ ),
215
+ ],
216
+ }
217
+ )
218
+ enriched.append(current)
219
+ continue
220
+ replacements = [current.suggestion] if current.suggestion else []
221
+ if current.claim.kind in history_kinds:
222
+ replacements.extend(symbol.name for symbol in index.by_kind.get(history_kinds[current.claim.kind], []))
223
+ if current.claim.kind == ClaimKind.default_value:
224
+ replacements.extend(symbol.name for symbol in index.by_kind.get(SymbolKind.config_key, []))
225
+ old_history_name = current.claim.value
226
+ if current.claim.kind == ClaimKind.endpoint:
227
+ old_history_name = current.claim.raw_text.strip("`").split(maxsplit=1)[-1]
228
+ replacements = [name.split(maxsplit=1)[-1] for name in replacements]
229
+ replacements.extend(
230
+ symbol.raw_path or symbol.name.split(maxsplit=1)[-1]
231
+ for symbol in index.by_kind.get(SymbolKind.route, [])
232
+ )
233
+ match = history.search(old_history_name, tuple(replacements))
234
+ if match.rename:
235
+ replacement_name = match.rename.new_name.strip('"')
236
+ replacement_symbol = next(
237
+ (
238
+ symbol
239
+ for symbol in index.symbols
240
+ if (
241
+ symbol.name == replacement_name
242
+ or symbol.name.endswith(" " + replacement_name)
243
+ or symbol.raw_path == replacement_name
244
+ )
245
+ and symbol.file == match.rename.file
246
+ ),
247
+ None,
248
+ )
249
+ verdict: Verdict = current.verdict
250
+ suggestion = current.suggestion
251
+ reasons = list(current.reasons)
252
+ if default_without_subject and replacement_symbol and replacement_symbol.default is not None:
253
+ expected = normalize_value(current.claim.expected_default or "", subject=current.claim.value)
254
+ actual = normalize_value(replacement_symbol.default, subject=replacement_name)
255
+ equality = expected.equals(actual)
256
+ verdict = Verdict.default_changed if equality is False else Verdict.renamed
257
+ suggestion = (
258
+ f"{replacement_name} (default {replacement_symbol.default})"
259
+ if equality is False
260
+ else replacement_name
261
+ )
262
+ reasons.append(
263
+ f"The documented setting was renamed to {replacement_name} and its code default is "
264
+ f"{replacement_symbol.default}."
265
+ )
266
+ elif current.verdict in {Verdict.missing, Verdict.unverifiable} and (
267
+ current.claim.kind != ClaimKind.env_var or _same_env_concept(current.claim.value, replacement_name)
268
+ ):
269
+ verdict = Verdict.renamed
270
+ suggestion = replacement_name
271
+ reasons.append(f"Git history confirms this name changed to {replacement_name}.")
272
+ current = current.model_copy(
273
+ update={
274
+ "verdict": verdict,
275
+ "suggestion": suggestion,
276
+ "matched_symbol": replacement_symbol or current.matched_symbol,
277
+ "confidence": max(current.confidence, 0.92),
278
+ "evidence": [
279
+ *current.evidence,
280
+ Evidence(
281
+ file=match.rename.file or current.claim.doc_path,
282
+ line=1,
283
+ text=match.rename.hunk_excerpt,
284
+ ),
285
+ ],
286
+ "reasons": [*reasons, f"Git history changed this name in {match.rename.commit.short_sha}."],
287
+ }
288
+ )
289
+ if match.commit:
290
+ current = current.model_copy(update={"introduced_by": match.commit})
291
+ enriched.append(current)
292
+ return enriched, history.warnings
293
+
294
+
295
+ @app.command(cls=Command)
296
+ def scan(
297
+ ctx: typer.Context,
298
+ claims_only: Annotated[
299
+ bool, typer.Option("--claims-only", help="Run extraction only and list documented claims.")
300
+ ] = False,
301
+ show_suppressed: Annotated[
302
+ bool, typer.Option("--show-suppressed", help="Include claims hidden by inline suppressions.")
303
+ ] = False,
304
+ show_all: Annotated[bool, typer.Option("--all", help="Include OK findings.")] = False,
305
+ show_low_confidence: Annotated[bool, typer.Option("--show-low-confidence", help="Include weak findings.")] = False,
306
+ llm: Annotated[bool, typer.Option("--llm", help="Assess anchored prose through OpenRouter.")] = False,
307
+ llm_budget: Annotated[float | None, typer.Option("--llm-budget")] = None,
308
+ docs_repo: Annotated[
309
+ Path | None, typer.Option("--docs-repo", help="Read documentation from a separate repository.")
310
+ ] = None,
311
+ package: Annotated[str | None, typer.Option("--package", help="Scan only the named workspace package.")] = None,
312
+ ) -> None:
313
+ """Scan a repository and print a report."""
314
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
315
+ if claims_only:
316
+ result = scan_claims(docs_repo or options.repo, options.config)
317
+ if options.output_format != OutputFormat.terminal:
318
+ _print_findings(result, options, show_all=True, show_low_confidence=True)
319
+ else:
320
+ _print_claims(result, options, show_suppressed)
321
+ return
322
+ import sys # noqa: PLC0415 - optional dependency or shared pipeline import.
323
+
324
+ from docs_drift.scanning import run_scan # noqa: PLC0415 - optional dependency or shared pipeline import.
325
+
326
+ result = run_scan(
327
+ options.repo,
328
+ options.config,
329
+ docs_repo=docs_repo,
330
+ package=package,
331
+ progress=sys.stderr.isatty() and not options.quiet and not options.no_color,
332
+ )
333
+ result = _model_layer(result, llm, llm_budget)
334
+ _print_findings(result, options, show_all=show_all, show_low_confidence=show_low_confidence)
335
+
336
+
337
+ def _print_claims(result: ScanResult, options: CLIOptions, show_suppressed: bool) -> None:
338
+ console = Console(
339
+ force_terminal=sys.stdout.isatty() and not options.no_color, markup=False, no_color=options.no_color, width=180
340
+ )
341
+ table = Table(title="Documentation claims")
342
+ for heading in (
343
+ "doc:line",
344
+ "section",
345
+ "kind",
346
+ "value",
347
+ "qualifier",
348
+ "expected default",
349
+ "source",
350
+ "confidence",
351
+ ):
352
+ table.add_column(heading)
353
+ docs = {doc.path for doc in result.docs}
354
+ for path in sorted(docs):
355
+ parsed_claims = [claim for claim in result.claims if claim.doc_path == path]
356
+ section_by_id = {section.section_id: section for section in result.sections if section.doc_path == path}
357
+ parsed_claims.sort(key=lambda claim: (section_by_id[claim.section_id].heading_path, claim.line, claim.column))
358
+ for claim in parsed_claims:
359
+ table.add_row(
360
+ f"{claim.doc_path}:{claim.line}",
361
+ " / ".join(section_by_id[claim.section_id].heading_path),
362
+ claim.kind.value,
363
+ claim.value,
364
+ claim.qualifier or "",
365
+ claim.expected_default or "",
366
+ ", ".join(source.value for source in claim.extraction_sources),
367
+ f"{claim.extraction_confidence:.2f}",
368
+ )
369
+ console.print(table)
370
+ if show_suppressed and result.suppressed:
371
+ suppressed_table = Table(title="Suppressed claims")
372
+ suppressed_table.add_column("location")
373
+ suppressed_table.add_column("claim")
374
+ suppressed_table.add_column("reason")
375
+ for item in result.suppressed:
376
+ suppressed_table.add_row(f"{item.claim.doc_path}:{item.claim.line}", item.claim.value, item.reason)
377
+ console.print(suppressed_table)
378
+ by_kind: dict[str, int] = {}
379
+ for claim in result.claims:
380
+ by_kind[claim.kind.value] = by_kind.get(claim.kind.value, 0) + 1
381
+ console.print("Claims: " + ", ".join(f"{kind}={count}" for kind, count in sorted(by_kind.items())))
382
+ console.print(f"Suppressed: {len(result.suppressed)} | historical ignored: {result.historical_ignored}")
383
+ for warning in result.warnings:
384
+ console.print(f"Warning: {warning}", style="yellow")
385
+ if options.verbose:
386
+ console.print(
387
+ "Timings: " + ", ".join(f"{name}={seconds:.4f}s" for name, seconds in sorted(result.timings.items()))
388
+ )
389
+
390
+
391
+ def _print_findings(
392
+ result: ScanResult,
393
+ options: CLIOptions,
394
+ *,
395
+ show_all: bool,
396
+ show_low_confidence: bool,
397
+ ) -> None:
398
+ import shutil # noqa: PLC0415 - optional dependency or shared pipeline import.
399
+ import sys # noqa: PLC0415 - optional dependency or shared pipeline import.
400
+
401
+ tty = sys.stdout.isatty() and not options.output
402
+ render_options = RenderOptions(
403
+ color=tty and not options.no_color,
404
+ hyperlinks=tty and not options.no_color,
405
+ width=shutil.get_terminal_size((100, 24)).columns,
406
+ verbose=options.verbose,
407
+ show_all=show_all,
408
+ show_low_confidence=show_low_confidence,
409
+ show_authors=options.show_authors,
410
+ )
411
+ destinations = [
412
+ (options.output_format.value, options.output),
413
+ ("json", options.output_json),
414
+ ("sarif", options.output_sarif),
415
+ ]
416
+ for number, (format_name, destination) in enumerate(destinations):
417
+ if number and destination is None:
418
+ continue
419
+ text = render(result, format_name, render_options)
420
+ if destination:
421
+ destination.parent.mkdir(parents=True, exist_ok=True)
422
+ destination.write_text(text, encoding="utf-8", newline="\n")
423
+ elif not options.quiet:
424
+ typer.echo(text, nl=False, color=render_options.color)
425
+
426
+
427
+ @app.command(cls=Command)
428
+ def check(
429
+ ctx: typer.Context,
430
+ fail_on: Annotated[str, typer.Option("--fail-on", help="Exit 1 on drift, missing, or any finding.")] = "drift",
431
+ min_confidence: Annotated[float | None, typer.Option("--min-confidence")] = None,
432
+ max_findings: Annotated[int | None, typer.Option("--max-findings")] = None,
433
+ baseline: Annotated[
434
+ Path | None, typer.Option("--baseline", help="Fail only for drifts absent from this baseline.")
435
+ ] = None,
436
+ reviews: Annotated[Path | None, typer.Option(help="Source-scoped human review decisions.")] = None,
437
+ quiet: Annotated[bool, typer.Option("--quiet", help="Print nothing unless the check fails.")] = False,
438
+ llm: Annotated[bool, typer.Option("--llm")] = False,
439
+ llm_budget: Annotated[float | None, typer.Option("--llm-budget")] = None,
440
+ ) -> None:
441
+ """Run a full scan; exit 1 for configured findings, or 2 for usage errors."""
442
+ if fail_on not in {"drift", "missing", "any"}:
443
+ raise typer.BadParameter("Choose drift, missing, or any.", param_hint="--fail-on")
444
+ if min_confidence is not None and not 0 <= min_confidence <= 1:
445
+ raise typer.BadParameter("Choose a confidence between 0 and 1.", param_hint="--min-confidence")
446
+ if max_findings is not None and max_findings < 0:
447
+ raise typer.BadParameter("Choose a non-negative count.", param_hint="--max-findings")
448
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
449
+ from docs_drift.scanning import run_scan # noqa: PLC0415 - optional dependency or shared pipeline import.
450
+
451
+ result = _model_layer(run_scan(options.repo, options.config), llm, llm_budget)
452
+ history_warnings: list[str] = []
453
+ findings = result.findings
454
+ baselined = 0
455
+ findings = result.findings
456
+ if baseline is not None:
457
+ baseline_path = baseline if baseline.is_absolute() else options.repo / baseline
458
+ try:
459
+ original_count = len(findings)
460
+ findings = new_findings(findings, load_baseline(baseline_path))
461
+ baselined = original_count - len(findings)
462
+ except ValueError as exc:
463
+ raise typer.BadParameter(str(exc), param_hint="--baseline") from exc
464
+ threshold = options.config.min_confidence if min_confidence is None else min_confidence
465
+ from docs_drift.review import load_reviews, suppressed_ids # noqa: PLC0415
466
+
467
+ suppressed = suppressed_ids(result, load_reviews(options.repo / reviews)) if reviews else set()
468
+ if suppressed:
469
+ history_warnings.append(
470
+ f"{len(suppressed)} source-scoped reviewed findings excluded from the gate; retained below."
471
+ )
472
+ qualifying = [item for item in findings if item.confidence >= threshold and item.claim.claim_id not in suppressed]
473
+ if fail_on == "drift":
474
+ failures = [item for item in qualifying if item.is_drift]
475
+ elif fail_on == "missing":
476
+ failures = [item for item in qualifying if item.verdict in {Verdict.missing, Verdict.invalid_command}]
477
+ else:
478
+ failures = [item for item in qualifying if item.verdict != Verdict.ok]
479
+ if max_findings is not None:
480
+ failures = failures if len(failures) > max_findings else []
481
+ if not (quiet or options.quiet) or failures or options.output or options.output_json or options.output_sarif:
482
+ _print_findings(
483
+ result.model_copy(
484
+ update={
485
+ "baselined": baselined,
486
+ "findings": findings,
487
+ "warnings": [*result.warnings, *history_warnings],
488
+ }
489
+ ),
490
+ replace(options, quiet=False) if failures else options,
491
+ show_all=fail_on == "any",
492
+ show_low_confidence=True,
493
+ )
494
+ if failures:
495
+ raise typer.Exit(code=1)
496
+
497
+
498
+ @app.command(cls=Command)
499
+ def explain(
500
+ ctx: typer.Context,
501
+ finding_id_prefix: Annotated[str, typer.Argument(metavar="FINDING_ID")],
502
+ llm: Annotated[bool, typer.Option("--llm")] = False,
503
+ ) -> None:
504
+ """Show documentation, source, reasons, confidence and history evidence for one finding."""
505
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
506
+ from docs_drift.scanning import run_scan # noqa: PLC0415 - optional dependency or shared pipeline import.
507
+
508
+ scan = _model_layer(run_scan(options.repo, options.config), llm, None)
509
+ findings = scan.findings
510
+ matches = [item for item in findings if item.claim.claim_id.startswith(finding_id_prefix)]
511
+ if len(matches) != 1:
512
+ raise typer.BadParameter("Use a prefix that identifies exactly one finding.", param_hint="FINDING_ID")
513
+ finding = matches[0]
514
+ if options.output_format != OutputFormat.terminal:
515
+ _print_findings(
516
+ scan.model_copy(update={"findings": [finding]}), options, show_all=True, show_low_confidence=True
517
+ )
518
+ return
519
+ payload = {
520
+ "id": finding.claim.claim_id,
521
+ "claim": finding.claim.model_dump(mode="json"),
522
+ "verdict": finding.verdict.value,
523
+ "suggestion": finding.suggestion,
524
+ "confidence": finding.confidence,
525
+ "decided_by": finding.decided_by.value,
526
+ "replayed": finding.replayed,
527
+ "reasons": finding.reasons,
528
+ "evidence": [item.model_dump(mode="json") for item in finding.evidence],
529
+ "matched_symbol": finding.matched_symbol.model_dump(mode="json") if finding.matched_symbol else None,
530
+ "introduced_by": finding.introduced_by.model_dump(mode="json") if finding.introduced_by else None,
531
+ }
532
+ Console(
533
+ force_terminal=sys.stdout.isatty() and not options.no_color, markup=False, no_color=options.no_color, width=140
534
+ ).print(Panel.fit(json.dumps(payload, indent=2), title="Finding evidence"))
535
+
536
+
537
+ @baseline_app.command("update", cls=Command)
538
+ def baseline_update(
539
+ ctx: typer.Context,
540
+ path: Path = Path(".docs-drift-baseline.json"),
541
+ llm: Annotated[bool, typer.Option("--llm")] = False,
542
+ ) -> None:
543
+ """Replace the baseline with current drift findings; fixed findings are pruned."""
544
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
545
+ from docs_drift.scanning import run_scan # noqa: PLC0415
546
+
547
+ scan = _model_layer(run_scan(options.repo, options.config), llm, None)
548
+ findings = scan.findings
549
+ target = path if path.is_absolute() else options.repo / path
550
+ save_baseline(target, findings)
551
+ typer.echo(f"Saved {sum(item.is_drift for item in findings)} findings to {target}.")
552
+
553
+
554
+ @app.command("ignore", cls=Command)
555
+ def ignore_claim(ctx: typer.Context, claim_id_prefix: Annotated[str, typer.Argument(metavar="CLAIM_ID")]) -> None:
556
+ """Add a stable claim ID to the repository ignore list."""
557
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
558
+ scan = scan_claims(options.repo, options.config)
559
+ matches = [claim for claim in scan.claims if claim.claim_id.startswith(claim_id_prefix)]
560
+ if len(matches) != 1:
561
+ raise typer.BadParameter("Use a prefix that identifies exactly one claim.", param_hint="CLAIM_ID")
562
+ path = options.repo / ".docs-drift.toml"
563
+ data: dict[str, object] = {}
564
+ if not path.exists():
565
+ pyproject = options.repo / "pyproject.toml"
566
+ if pyproject.is_file():
567
+ try:
568
+ source = tomllib.loads(pyproject.read_text(encoding="utf-8", errors="replace"))
569
+ tool = source.get("tool", {})
570
+ if isinstance(tool, dict) and isinstance(tool.get("docs-drift"), dict):
571
+ data = tool["docs-drift"]
572
+ except tomllib.TOMLDecodeError as exc:
573
+ raise typer.BadParameter(f"Cannot read pyproject.toml: {exc}.") from exc
574
+ else:
575
+ try:
576
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
577
+ except (OSError, tomllib.TOMLDecodeError) as exc:
578
+ raise typer.BadParameter(f"Cannot update {path}: {exc}.") from exc
579
+ ignored = data.get("ignore", [])
580
+ if not isinstance(ignored, list) or not all(isinstance(item, str) for item in ignored):
581
+ raise typer.BadParameter("The ignore setting must be a list of strings.")
582
+ if matches[0].claim_id not in ignored:
583
+ data["ignore"] = [*ignored, matches[0].claim_id]
584
+ path.write_text(tomli_w.dumps(data), encoding="utf-8", newline="\n")
585
+ typer.echo(f"Ignored {matches[0].claim_id} in {path}.")
586
+
587
+
588
+ @app.command(cls=Command)
589
+ def fix( # noqa: PLR0912 - preview, confirmation, and stale-scan retry paths.
590
+ ctx: typer.Context,
591
+ write: Annotated[bool, typer.Option("--write", help="Apply edits after confirmation per file.")] = False,
592
+ yes: Annotated[bool, typer.Option("--yes", help="Skip --write confirmations.")] = False,
593
+ patch: Annotated[Path | None, typer.Option("--patch", help="Write a git-applyable patch.")] = None,
594
+ llm: Annotated[bool, typer.Option("--llm")] = False,
595
+ llm_budget: Annotated[float | None, typer.Option("--llm-budget")] = None,
596
+ ) -> None:
597
+ """Preview exact documentation edits; only --write applies them."""
598
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
599
+ for attempt in range(2):
600
+ from docs_drift.scanning import run_scan # noqa: PLC0415
601
+
602
+ scan_result = run_scan(options.repo, options.config)
603
+ index = build_index(options.repo, options.config)
604
+ scan_result = _model_layer(scan_result, llm, llm_budget)
605
+ try:
606
+ plan = plan_rule_fixes(scan_result)
607
+ if llm or options.config.llm.enabled:
608
+ try:
609
+ plan = asyncio.run(add_model_fixes(scan_result, index, plan))
610
+ except (ClientError, ValueError) as exc:
611
+ raise typer.BadParameter(str(exc), param_hint="--llm") from exc
612
+ diff = unified_patch(options.repo, plan)
613
+ if options.output_format == OutputFormat.json:
614
+ typer.echo(plan.model_dump_json(indent=2))
615
+ else:
616
+ console = Console(
617
+ force_terminal=sys.stdout.isatty() and not options.no_color,
618
+ markup=False,
619
+ no_color=options.no_color,
620
+ highlight=False,
621
+ )
622
+ for line in diff.splitlines(keepends=True):
623
+ style = "green" if line.startswith("+") else "red" if line.startswith("-") else None
624
+ console.print(line, style=style, end="", markup=False)
625
+ for note in plan.suggestions:
626
+ console.print(f"Review: {note}", markup=False)
627
+ fixed = len({edit.claim_id for edit in plan.edits})
628
+ total = sum(item.is_drift for item in scan_result.findings)
629
+ console.print(f"Fixes: {fixed} auto-fixable, {max(0, total - fixed)} suggestion-only findings.")
630
+ if scan_result.llm_usage:
631
+ console.print(format_usage(scan_result.llm_usage))
632
+ if any(edit.decided_by == "llm" for edit in plan.edits):
633
+ console.print(
634
+ "Model-assisted sentence edits passed static validation; review them before applying."
635
+ )
636
+ if any(item.replayed for item in scan_result.findings) or any(edit.replayed for edit in plan.edits):
637
+ console.print("Model verdicts replayed from recordings.")
638
+ if patch:
639
+ patch.write_bytes(diff.encode("utf-8"))
640
+ if write:
641
+ for relative in sorted({edit.file for edit in plan.edits}):
642
+ if yes or typer.confirm(f"Apply proposed edits to {relative}?", default=False):
643
+ apply_file(options.repo, plan, relative)
644
+ return
645
+ except StaleDocumentError as exc:
646
+ if attempt:
647
+ raise typer.BadParameter(str(exc)) from exc
648
+ typer.echo(f"{exc} Re-scanning before presenting fresh edits.", err=True)
649
+
650
+
651
+ @app.command(cls=Command)
652
+ def graph(ctx: typer.Context) -> None:
653
+ """Export a scan with the evidence graph in HTML or JSON."""
654
+ from docs_drift.scanning import run_scan # noqa: PLC0415 - optional dependency or shared pipeline import.
655
+
656
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
657
+ _print_findings(run_scan(options.repo, options.config), options, show_all=True, show_low_confidence=True)
658
+
659
+
660
+ @app.command("insights", cls=Command)
661
+ def insights_command(ctx: typer.Context) -> None:
662
+ """Summarize findings by verdict, document and workspace package."""
663
+ from docs_drift.analysis import insights # noqa: PLC0415 - optional command path.
664
+ from docs_drift.scanning import run_scan # noqa: PLC0415
665
+
666
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
667
+ text = json.dumps(insights(run_scan(options.repo, options.config)), indent=2, sort_keys=True) + "\n"
668
+ if options.output:
669
+ options.output.write_text(text, encoding="utf-8")
670
+ elif not options.quiet:
671
+ typer.echo(text, nl=False)
672
+
673
+
674
+ @app.command("impact", cls=Command)
675
+ def impact_command(
676
+ ctx: typer.Context,
677
+ base: Annotated[str, typer.Option(help="Base revision, for example HEAD~1.")],
678
+ head: Annotated[str, typer.Option(help="Current checkout revision.")] = "HEAD",
679
+ ) -> None:
680
+ """Show current findings connected to documentation or source changed since a revision."""
681
+ from docs_drift.analysis import impact # noqa: PLC0415 - optional command path.
682
+
683
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
684
+ _print_findings(impact(options.repo, options.config, base, head), options, show_all=True, show_low_confidence=True)
685
+
686
+
687
+ @app.command(cls=Command)
688
+ def init(
689
+ ctx: typer.Context,
690
+ yes: Annotated[bool, typer.Option("--yes", help="Accept detected defaults without prompting.")] = False,
691
+ ) -> None:
692
+ """Detect documentation and write a commented repository configuration."""
693
+ from docs_drift.setup import initialize # noqa: PLC0415 - optional dependency or shared pipeline import.
694
+
695
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
696
+ initialize(options.repo, yes=yes, quiet=options.quiet)
697
+
698
+
699
+ @app.command(cls=Command)
700
+ def serve(
701
+ ctx: typer.Context, port: Annotated[int, typer.Option(help="Local HTTP port.", min=1, max=65535)] = 8000
702
+ ) -> None:
703
+ """Serve a local HTML report in the browser."""
704
+ from functools import partial # noqa: PLC0415 - optional dependency or shared pipeline import.
705
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer # noqa: PLC0415 - optional dependency or shared pipeline import.
706
+
707
+ from docs_drift.scanning import run_scan # noqa: PLC0415 - optional dependency or shared pipeline import.
708
+
709
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
710
+ directory = options.repo / ".docs-drift" / "report"
711
+ directory.mkdir(parents=True, exist_ok=True)
712
+ (directory / "index.html").write_text(render(run_scan(options.repo, options.config), "html"), encoding="utf-8")
713
+ typer.echo(f"Report available at http://127.0.0.1:{port}; press Ctrl+C to stop.")
714
+ with ThreadingHTTPServer(
715
+ ("127.0.0.1", port), partial(SimpleHTTPRequestHandler, directory=str(directory))
716
+ ) as server:
717
+ server.serve_forever()
718
+
719
+
720
+ @app.command("index", cls=Command)
721
+ def index_command( # noqa: PLR0912
722
+ ctx: typer.Context,
723
+ list_kind: Annotated[str | None, typer.Option("--list", help="List symbols of this kind.")] = None,
724
+ find: Annotated[str | None, typer.Option("--find", help="Fuzzy-search indexed symbols.")] = None,
725
+ json_output: Annotated[bool, typer.Option("--json", help="Write the complete index as JSON.")] = False,
726
+ no_cache: Annotated[bool, typer.Option("--no-cache", help="Skip reading and writing the index cache.")] = False,
727
+ tree: Annotated[bool, typer.Option("--tree", help="Show indexed commands and flags as a tree.")] = False,
728
+ ) -> None:
729
+ """Build or refresh the static source index."""
730
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
731
+ started = time.perf_counter()
732
+ result = build_index(options.repo, options.config, no_cache=no_cache)
733
+ elapsed = time.perf_counter() - started
734
+ if json_output or options.output_format == OutputFormat.json:
735
+ typer.echo(
736
+ json.dumps(
737
+ {
738
+ "symbols": [item.model_dump(mode="json") for item in result.symbols],
739
+ "coverage": result.coverage,
740
+ "warnings": result.warnings,
741
+ },
742
+ indent=2,
743
+ sort_keys=True,
744
+ )
745
+ )
746
+ return
747
+ if tree:
748
+ command_tree = Tree(options.repo.resolve().name)
749
+ command_symbols = [item for item in result.symbols if item.kind.value == "cli_command"]
750
+ flag_symbols = [item for item in result.symbols if item.kind.value == "cli_flag"]
751
+ branches: dict[str, Tree] = {}
752
+ for command_symbol in command_symbols:
753
+ if command_symbol.name.startswith("npx:"):
754
+ continue
755
+ branch = command_tree.add(command_symbol.name)
756
+ branches[command_symbol.name] = branch
757
+ for flag_symbol in flag_symbols:
758
+ matching = next(
759
+ (
760
+ name
761
+ for name in sorted(branches, key=len, reverse=True)
762
+ if f".{name}." in f".{flag_symbol.qualified_name}."
763
+ ),
764
+ None,
765
+ )
766
+ if matching is not None:
767
+ branches[matching].add(flag_symbol.name)
768
+ Console(
769
+ force_terminal=sys.stdout.isatty() and not options.no_color, markup=False, no_color=options.no_color
770
+ ).print(command_tree)
771
+ return
772
+ if list_kind:
773
+ matches = [item for item in result.symbols if item.kind.value == list_kind]
774
+ table = Table(title=f"Symbols: {list_kind}")
775
+ for heading in ("name", "qualified name", "file:line", "default", "framework"):
776
+ table.add_column(heading)
777
+ for item in matches:
778
+ table.add_row(
779
+ item.name, item.qualified_name, f"{item.file}:{item.line}", item.default or "", item.framework or ""
780
+ )
781
+ Console(
782
+ force_terminal=sys.stdout.isatty() and not options.no_color,
783
+ markup=False,
784
+ no_color=options.no_color,
785
+ width=180,
786
+ ).print(table)
787
+ return
788
+ if find:
789
+ choices = {f"{item.name} {item.qualified_name} {item.file}:{item.line}": item for item in result.symbols}
790
+ fuzzy_matches = process.extract(find, list(choices), limit=10, score_cutoff=35)
791
+ for choice, score, _ in fuzzy_matches:
792
+ typer.echo(f"{score:5.1f} {choice}")
793
+ return
794
+ kinds: dict[str, int] = {}
795
+ frameworks = sorted({item.framework for item in result.symbols if item.framework})
796
+ table = Table(title="Code index")
797
+ table.add_column("language/files")
798
+ table.add_column("count", justify="right")
799
+ for language, count in sorted(result.files_by_language.items()):
800
+ table.add_row(language, str(count))
801
+ for item in result.symbols:
802
+ kinds[item.kind.value] = kinds.get(item.kind.value, 0) + 1
803
+ Console(
804
+ force_terminal=sys.stdout.isatty() and not options.no_color, markup=False, no_color=options.no_color, width=180
805
+ ).print(table)
806
+ typer.echo("Symbols: " + ", ".join(f"{kind}={count}" for kind, count in sorted(kinds.items())))
807
+ typer.echo("Frameworks: " + (", ".join(frameworks) or "none detected"))
808
+ typer.echo(f"Cache: hits={result.cache_hits}, misses={result.cache_misses}; elapsed={elapsed:.3f}s")
809
+ for warning in result.warnings:
810
+ typer.echo(f"Warning: {warning}", err=True)
811
+
812
+
813
+ @cache_app.command("clear", cls=Command)
814
+ def clear_index_cache(ctx: typer.Context) -> None:
815
+ """Remove this repository's cached static index."""
816
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
817
+ typer.echo("Index cache cleared." if clear_cache(options.repo) else "No index cache found.")
818
+
819
+
820
+ @app.command(cls=Command)
821
+ def portfolio(
822
+ ctx: typer.Context,
823
+ manifest: Annotated[Path, typer.Option("--manifest", help="TOML repository manifest, relative to --repo.")],
824
+ report_dir: Annotated[Path, typer.Option("--report-dir", help="Report directory outside all scanned checkouts.")],
825
+ report_only: Annotated[
826
+ bool, typer.Option("--report-only", help="Collect reports without failing on drift.")
827
+ ] = False,
828
+ ) -> None:
829
+ """Scan up to 100 local checkouts and generate an organization dashboard."""
830
+ from docs_drift.portfolio import scan_portfolio # noqa: PLC0415 - load only for portfolio scans.
831
+
832
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
833
+ manifest_path = manifest if manifest.is_absolute() else options.repo / manifest
834
+ destination = report_dir if report_dir.is_absolute() else options.repo / report_dir
835
+ try:
836
+ result = scan_portfolio(options.repo, manifest_path, destination, report_only=report_only)
837
+ except (OSError, ValueError) as exc:
838
+ raise typer.BadParameter(str(exc), param_hint="--manifest / --report-dir") from exc
839
+ for repository in result.repositories:
840
+ counts = f"; {repository.gate.new_drift} new drift" if repository.gate else ""
841
+ typer.echo(f"{repository.name}: {repository.status}{counts}")
842
+ typer.echo(f"Dashboard: {destination.resolve() / 'index.html'}")
843
+ if result.exit_code:
844
+ raise typer.Exit(code=result.exit_code)
845
+
846
+
847
+ app.add_typer(cache_app, name="cache")
848
+ app.add_typer(baseline_app, name="baseline")
849
+
850
+
851
+ @app.command(cls=Command)
852
+ def watch(ctx: typer.Context) -> None:
853
+ """Recheck changed documents and source files with immediate feedback."""
854
+ from docs_drift.watch import watch_repository # noqa: PLC0415 - optional dependency or shared pipeline import.
855
+
856
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
857
+ watch_repository(options.repo, options.config, quiet=options.quiet)
858
+
859
+
860
+ @app.command(cls=Command)
861
+ def lsp(ctx: typer.Context) -> None:
862
+ """Start the language server over standard input and output."""
863
+ from docs_drift.lsp import start # noqa: PLC0415 - optional dependency or shared pipeline import.
864
+
865
+ options: CLIOptions = ctx.ensure_object(CLIOptions)
866
+ start(options.repo, options.config)
867
+
868
+
869
+ from docs_drift.workflow_cli import register as register_workflows # noqa: E402
870
+
871
+ register_workflows(app)