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.
- docs_drift/__init__.py +7 -0
- docs_drift/analysis.py +52 -0
- docs_drift/baseline.py +70 -0
- docs_drift/cli.py +871 -0
- docs_drift/collaboration.py +282 -0
- docs_drift/config.py +113 -0
- docs_drift/docs/__init__.py +1 -0
- docs_drift/docs/defaults.py +250 -0
- docs_drift/docs/discover.py +172 -0
- docs_drift/docs/extract_blocks.py +596 -0
- docs_drift/docs/extract_inline.py +561 -0
- docs_drift/docs/pipeline.py +234 -0
- docs_drift/docs/preprocess.py +71 -0
- docs_drift/docs/sections.py +247 -0
- docs_drift/errors.py +119 -0
- docs_drift/fix/__init__.py +1 -0
- docs_drift/fix/edits.py +124 -0
- docs_drift/fix/model.py +139 -0
- docs_drift/fix/rules.py +232 -0
- docs_drift/git_safety.py +35 -0
- docs_drift/graph/queries/evidence_path.cypher +6 -0
- docs_drift/history.py +412 -0
- docs_drift/history_cache.py +62 -0
- docs_drift/ids.py +50 -0
- docs_drift/index/__init__.py +211 -0
- docs_drift/index/base.py +239 -0
- docs_drift/index/config.py +185 -0
- docs_drift/index/js_cli.py +211 -0
- docs_drift/index/js_routes.py +68 -0
- docs_drift/index/js_symbols.py +219 -0
- docs_drift/index/python_cli.py +370 -0
- docs_drift/index/python_routes.py +102 -0
- docs_drift/index/python_source.py +20 -0
- docs_drift/index/python_symbols.py +335 -0
- docs_drift/index/queries/javascript.scm +4 -0
- docs_drift/index/queries/typescript.scm +4 -0
- docs_drift/index/routes_common.py +16 -0
- docs_drift/index/tasks.py +131 -0
- docs_drift/index/ts_parser.py +20 -0
- docs_drift/limits.py +18 -0
- docs_drift/llm/__init__.py +7 -0
- docs_drift/llm/candidates.py +81 -0
- docs_drift/llm/client.py +185 -0
- docs_drift/llm/pricing.py +54 -0
- docs_drift/llm/privacy.py +63 -0
- docs_drift/llm/prompts/rewrite_v1.md +9 -0
- docs_drift/llm/prompts/verify_v1.md +12 -0
- docs_drift/llm/prompts.py +28 -0
- docs_drift/llm/retrieve.py +60 -0
- docs_drift/llm/types.py +68 -0
- docs_drift/llm/verify.py +179 -0
- docs_drift/lsp.py +199 -0
- docs_drift/match/__init__.py +5 -0
- docs_drift/match/commands.py +201 -0
- docs_drift/match/defaults.py +79 -0
- docs_drift/match/engine.py +319 -0
- docs_drift/match/rename.py +69 -0
- docs_drift/models.py +249 -0
- docs_drift/ownership.py +70 -0
- docs_drift/policy.py +81 -0
- docs_drift/portfolio.py +147 -0
- docs_drift/py.typed +0 -0
- docs_drift/report/__init__.py +38 -0
- docs_drift/report/common.py +31 -0
- docs_drift/report/github.py +22 -0
- docs_drift/report/html.py +106 -0
- docs_drift/report/json.py +17 -0
- docs_drift/report/junit.py +30 -0
- docs_drift/report/markdown.py +58 -0
- docs_drift/report/partials/body.html +19 -0
- docs_drift/report/partials/report.css +12 -0
- docs_drift/report/partials/script.js +95 -0
- docs_drift/report/portfolio.html +14 -0
- docs_drift/report/report.html +8 -0
- docs_drift/report/sarif.py +68 -0
- docs_drift/report/terminal.py +90 -0
- docs_drift/report/vendor/CYTOSCAPE-LICENSE +19 -0
- docs_drift/report/vendor/cytoscape.min.js +32 -0
- docs_drift/report/workflow.html +12 -0
- docs_drift/review.py +113 -0
- docs_drift/safe_files.py +19 -0
- docs_drift/scanning.py +202 -0
- docs_drift/schemas/SARIF-LICENSE.md +7 -0
- docs_drift/schemas/SARIF-NOTICES.txt +3 -0
- docs_drift/schemas/sarif-schema-2.1.0.json +3389 -0
- docs_drift/schemas/scan-result.v1.json +1024 -0
- docs_drift/setup.py +130 -0
- docs_drift/snapshots.py +100 -0
- docs_drift/support.py +31 -0
- docs_drift/watch.py +157 -0
- docs_drift/web/__init__.py +1 -0
- docs_drift/web/__main__.py +23 -0
- docs_drift/web/app.py +289 -0
- docs_drift/web/cleanup.py +23 -0
- docs_drift/web/examples/clean-repo.json +646 -0
- docs_drift/web/examples/fastapi-svc.json +723 -0
- docs_drift/web/examples/files-to-prompt.json +3366 -0
- docs_drift/web/examples/judge-fixture.json +226 -0
- docs_drift/web/examples/manifest.json +89 -0
- docs_drift/web/examples/node-tool.json +823 -0
- docs_drift/web/examples/py-cli.json +1267 -0
- docs_drift/web/examples/rich-cli-reviewed.json +7855 -0
- docs_drift/web/examples/rich-cli.json +8010 -0
- docs_drift/web/examples/verification.json +16 -0
- docs_drift/web/git.py +274 -0
- docs_drift/web/jobs.py +352 -0
- docs_drift/web/settings.py +50 -0
- docs_drift/web/static/demo.css +37 -0
- docs_drift/web/static/demo.js +184 -0
- docs_drift/web/static/evidence/correction-verification.json +28 -0
- docs_drift/web/static/evidence/flow-highlights-2026-10-22.png +0 -0
- docs_drift/web/static/evidence/fresh-scan.json +7855 -0
- docs_drift/web/static/evidence/review.json +44 -0
- docs_drift/web/static/evidence/rich-cli-correction.patch +20 -0
- docs_drift/web/static/evidence/runtime.json +26 -0
- docs_drift/web/static/evidence/saved-scan.json +8010 -0
- docs_drift/web/static/evidence/usage-card-supplied.png +0 -0
- docs_drift/web/templates/base.html +34 -0
- docs_drift/web/templates/case.html +13 -0
- docs_drift/web/templates/landing.html +90 -0
- docs_drift/web/templates/scan.html +2 -0
- docs_drift/web/templates/voice.html +1 -0
- docs_drift/web/validation.py +47 -0
- docs_drift/web/worker.py +152 -0
- docs_drift/workflow_cli.py +96 -0
- docs_drift/workspaces.py +128 -0
- docs_drift-0.1.0.dist-info/METADATA +331 -0
- docs_drift-0.1.0.dist-info/RECORD +131 -0
- docs_drift-0.1.0.dist-info/WHEEL +4 -0
- docs_drift-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|