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
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""Extract claims from fenced examples as untrusted text; no snippet is executed."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import configparser
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
import tomllib
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from markdown_it.token import Token
|
|
18
|
+
|
|
19
|
+
from docs_drift.docs.sections import ParsedDoc, is_historical_span
|
|
20
|
+
from docs_drift.ids import make_claim_id
|
|
21
|
+
from docs_drift.models import Claim, ClaimKind, Extraction
|
|
22
|
+
from docs_drift.safe_files import metadata_text
|
|
23
|
+
|
|
24
|
+
_SHELL_LANGS = {"bash", "sh", "shell", "console", "zsh", "powershell", "ps1", "cmd"}
|
|
25
|
+
_LANGS = _SHELL_LANGS | {
|
|
26
|
+
"python",
|
|
27
|
+
"py",
|
|
28
|
+
"js",
|
|
29
|
+
"javascript",
|
|
30
|
+
"ts",
|
|
31
|
+
"typescript",
|
|
32
|
+
"json",
|
|
33
|
+
"yaml",
|
|
34
|
+
"yml",
|
|
35
|
+
"toml",
|
|
36
|
+
"env",
|
|
37
|
+
"dotenv",
|
|
38
|
+
"http",
|
|
39
|
+
}
|
|
40
|
+
_ASSIGNMENT = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
|
41
|
+
_CURL_METHOD = re.compile(r"(?:^|\s)(?:-X|--request)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b", re.I)
|
|
42
|
+
_URL = re.compile(r"https?://[^\s'\"]+", re.I)
|
|
43
|
+
ClaimCounter = Counter[tuple[str, ClaimKind, str]]
|
|
44
|
+
MIN_MODULE_COMMAND_TOKENS = 3
|
|
45
|
+
MIN_WRAPPED_COMMAND_TOKENS = 3
|
|
46
|
+
MIN_COMMAND_ARGUMENTS = 2
|
|
47
|
+
WRAPPED_COMMAND_OFFSET = 3
|
|
48
|
+
COMMAND_TARGET_OFFSET = 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class BlockExtractionResult:
|
|
53
|
+
claims: list[Claim]
|
|
54
|
+
historical_skipped: int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def project_command_names(repo: Path) -> tuple[set[str], set[str]]: # noqa: PLR0912
|
|
58
|
+
"""Read conventional packaging metadata and return executable names and Python modules."""
|
|
59
|
+
commands: set[str] = set()
|
|
60
|
+
modules: set[str] = set()
|
|
61
|
+
pyproject = repo / "pyproject.toml"
|
|
62
|
+
if pyproject.is_file():
|
|
63
|
+
try:
|
|
64
|
+
metadata = tomllib.loads(metadata_text(pyproject))
|
|
65
|
+
scripts = metadata.get("project", {}).get("scripts", {})
|
|
66
|
+
if isinstance(scripts, dict):
|
|
67
|
+
commands.update(str(name) for name in scripts)
|
|
68
|
+
for name in metadata.get("project", {}).get("name", "").replace("-", "_").split():
|
|
69
|
+
modules.add(name)
|
|
70
|
+
distribution_name = metadata.get("project", {}).get("name", "")
|
|
71
|
+
if distribution_name:
|
|
72
|
+
commands.add(distribution_name.casefold())
|
|
73
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
74
|
+
pass
|
|
75
|
+
package_json = repo / "package.json"
|
|
76
|
+
if package_json.is_file():
|
|
77
|
+
try:
|
|
78
|
+
metadata = json.loads(metadata_text(package_json))
|
|
79
|
+
name = metadata.get("name")
|
|
80
|
+
if isinstance(name, str) and name:
|
|
81
|
+
commands.add(name)
|
|
82
|
+
modules.add(name)
|
|
83
|
+
bin_value = metadata.get("bin", {})
|
|
84
|
+
if isinstance(bin_value, str) and isinstance(name, str):
|
|
85
|
+
commands.add(name)
|
|
86
|
+
elif isinstance(bin_value, dict):
|
|
87
|
+
commands.update(str(command) for command in bin_value)
|
|
88
|
+
except (OSError, json.JSONDecodeError):
|
|
89
|
+
pass
|
|
90
|
+
setup_cfg = repo / "setup.cfg"
|
|
91
|
+
if setup_cfg.is_file():
|
|
92
|
+
parser = configparser.ConfigParser()
|
|
93
|
+
try:
|
|
94
|
+
parser.read_string(metadata_text(setup_cfg))
|
|
95
|
+
for section in parser.sections():
|
|
96
|
+
if section.startswith("options.entry_points"):
|
|
97
|
+
for name, _ in parser.items(section):
|
|
98
|
+
commands.add(name.strip())
|
|
99
|
+
except (OSError, configparser.Error):
|
|
100
|
+
pass
|
|
101
|
+
for child in repo.iterdir() if repo.is_dir() else ():
|
|
102
|
+
if child.is_dir() and (child / "__init__.py").is_file():
|
|
103
|
+
modules.add(child.name)
|
|
104
|
+
elif child.is_file() and child.suffix == ".py":
|
|
105
|
+
modules.add(child.stem)
|
|
106
|
+
return commands, modules
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _block_type(token: Token) -> str:
|
|
110
|
+
info = token.info.strip().casefold()
|
|
111
|
+
language = info.split(maxsplit=1)[0] if info else ""
|
|
112
|
+
if language in _LANGS:
|
|
113
|
+
return language
|
|
114
|
+
content = token.content.lstrip()
|
|
115
|
+
first = content.splitlines()[0].strip() if content else ""
|
|
116
|
+
if first.startswith(("$ ", "> ")) or re.match(r"(?:npm|pip|uv|poetry|make|just|npx)\s+", first):
|
|
117
|
+
return "shell"
|
|
118
|
+
if content.startswith(("{", "[")):
|
|
119
|
+
return "json"
|
|
120
|
+
if re.match(r"^[A-Za-z_][\w.-]*\s*:\s*\S", first):
|
|
121
|
+
return "yaml"
|
|
122
|
+
return "text"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _continued_lines(content: str, language: str) -> list[tuple[int, str]]:
|
|
126
|
+
"""Join only the shell-specific continuation marker, retaining the first physical line."""
|
|
127
|
+
marker = "`" if language in {"powershell", "ps1"} else "^" if language == "cmd" else "\\"
|
|
128
|
+
lines = content.splitlines()
|
|
129
|
+
result: list[tuple[int, str]] = []
|
|
130
|
+
pending = ""
|
|
131
|
+
start = 1
|
|
132
|
+
for number, source in enumerate(lines, 1):
|
|
133
|
+
stripped = source.rstrip()
|
|
134
|
+
if not pending:
|
|
135
|
+
start = number
|
|
136
|
+
if stripped.endswith(marker):
|
|
137
|
+
pending += stripped[:-1] + " "
|
|
138
|
+
else:
|
|
139
|
+
result.append((start, pending + source))
|
|
140
|
+
pending = ""
|
|
141
|
+
if pending:
|
|
142
|
+
result.append((start, pending))
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _split_shell(line: str) -> list[str]:
|
|
147
|
+
"""Split common shell operators without invoking a shell or expanding variables."""
|
|
148
|
+
return [part.strip() for part in re.split(r"&&|\|\||[;|]", line) if part.strip()]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _tokens(command: str, language: str) -> list[str]:
|
|
152
|
+
try:
|
|
153
|
+
return shlex.split(command, posix=language not in {"powershell", "ps1", "cmd"}, comments=True)
|
|
154
|
+
except ValueError:
|
|
155
|
+
# Malformed quoting should be ignored as an uncertain example, not guessed into a claim.
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_project_command( # noqa: PLR0911
|
|
160
|
+
tokens: list[str], repo: Path, names: set[str], modules: set[str]
|
|
161
|
+
) -> bool:
|
|
162
|
+
if not tokens:
|
|
163
|
+
return False
|
|
164
|
+
executable = PurePosixPath(tokens[0].replace("\\", "/")).name.casefold()
|
|
165
|
+
names_folded = {name.casefold() for name in names}
|
|
166
|
+
if executable in names_folded or executable.removesuffix(".exe") in names_folded:
|
|
167
|
+
return True
|
|
168
|
+
if executable in {"python", "python3", "py"} and len(tokens) >= MIN_MODULE_COMMAND_TOKENS and tokens[1] == "-m":
|
|
169
|
+
return tokens[2].split(".", 1)[0] in modules
|
|
170
|
+
if executable == "npx" and len(tokens) >= MIN_COMMAND_ARGUMENTS:
|
|
171
|
+
return tokens[1].casefold() in names_folded
|
|
172
|
+
if executable in {"uv", "poetry"} and len(tokens) >= MIN_MODULE_COMMAND_TOKENS and tokens[1] == "run":
|
|
173
|
+
return True
|
|
174
|
+
if executable in {"npm", "pnpm", "yarn", "make", "just"} and len(tokens) >= MIN_COMMAND_ARGUMENTS:
|
|
175
|
+
return tokens[1] == "run" and len(tokens) >= MIN_MODULE_COMMAND_TOKENS if executable == "npm" else True
|
|
176
|
+
if tokens[0].startswith(("./", ".\\")) or "/" in tokens[0] or "\\" in tokens[0]:
|
|
177
|
+
candidate = (repo / tokens[0].replace("\\", "/")).resolve()
|
|
178
|
+
if candidate.is_relative_to(repo.resolve()) and candidate.exists():
|
|
179
|
+
return True
|
|
180
|
+
try:
|
|
181
|
+
result = subprocess.run(
|
|
182
|
+
[
|
|
183
|
+
"git",
|
|
184
|
+
"--no-pager",
|
|
185
|
+
"-c",
|
|
186
|
+
"core.fsmonitor=false",
|
|
187
|
+
"-c",
|
|
188
|
+
f"core.hooksPath={os.devnull}",
|
|
189
|
+
"-C",
|
|
190
|
+
str(repo),
|
|
191
|
+
"ls-files",
|
|
192
|
+
"--deleted",
|
|
193
|
+
"--",
|
|
194
|
+
tokens[0],
|
|
195
|
+
],
|
|
196
|
+
check=True,
|
|
197
|
+
capture_output=True,
|
|
198
|
+
text=True,
|
|
199
|
+
timeout=2,
|
|
200
|
+
)
|
|
201
|
+
return bool(result.stdout.strip())
|
|
202
|
+
except (OSError, subprocess.SubprocessError):
|
|
203
|
+
return False
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _claim( # noqa: PLR0913, PLR0917
|
|
208
|
+
parsed: ParsedDoc,
|
|
209
|
+
kind: ClaimKind,
|
|
210
|
+
value: str,
|
|
211
|
+
raw: str,
|
|
212
|
+
line: int,
|
|
213
|
+
column: int,
|
|
214
|
+
extraction: Extraction,
|
|
215
|
+
counts: ClaimCounter,
|
|
216
|
+
*,
|
|
217
|
+
qualifier: str | None = None,
|
|
218
|
+
expected_default: str | None = None,
|
|
219
|
+
confidence: float = 0.85,
|
|
220
|
+
) -> Claim | None:
|
|
221
|
+
section = parsed.line_to_section.get(line)
|
|
222
|
+
if section is None:
|
|
223
|
+
return None
|
|
224
|
+
key = (section.section_id, kind, value)
|
|
225
|
+
occurrence = counts[key]
|
|
226
|
+
counts[key] += 1
|
|
227
|
+
return Claim(
|
|
228
|
+
claim_id=make_claim_id(parsed.doc.path, section.heading_path, kind, value, occurrence),
|
|
229
|
+
kind=kind,
|
|
230
|
+
raw_text=raw.strip() or value,
|
|
231
|
+
value=value,
|
|
232
|
+
qualifier=qualifier,
|
|
233
|
+
expected_default=expected_default,
|
|
234
|
+
doc_path=parsed.doc.path,
|
|
235
|
+
line=line,
|
|
236
|
+
column=max(1, column),
|
|
237
|
+
section_id=section.section_id,
|
|
238
|
+
extraction=extraction,
|
|
239
|
+
extraction_confidence=confidence,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _shell_block( # noqa: PLR0912, PLR0913, PLR0915, PLR0917
|
|
244
|
+
parsed: ParsedDoc, token: Token, language: str, repo: Path, names: set[str], modules: set[str], counts: ClaimCounter
|
|
245
|
+
) -> list[Claim]:
|
|
246
|
+
if token.map is None:
|
|
247
|
+
return []
|
|
248
|
+
claims: list[Claim] = []
|
|
249
|
+
source_lines = token.content.splitlines()
|
|
250
|
+
prompt_mode = any(line.lstrip().startswith(("$ ", "> ")) for line in source_lines)
|
|
251
|
+
for local_line, source in _continued_lines(token.content, language):
|
|
252
|
+
stripped = source.lstrip()
|
|
253
|
+
if prompt_mode and not stripped.startswith(("$ ", "> ")):
|
|
254
|
+
continue
|
|
255
|
+
command_line = re.sub(r"^(?:\$\s|>\s)", "", stripped)
|
|
256
|
+
# Captured --help rows are evidence too, even though they are output rather than runnable commands.
|
|
257
|
+
help_flag = re.match(r"^\s*(--?[A-Za-z][\w-]*)\s+.*\[\s*default\s*:", source, re.I)
|
|
258
|
+
if help_flag:
|
|
259
|
+
line = token.map[0] + local_line + 1
|
|
260
|
+
item = _claim(
|
|
261
|
+
parsed,
|
|
262
|
+
ClaimKind.cli_flag,
|
|
263
|
+
help_flag.group(1),
|
|
264
|
+
source.strip(),
|
|
265
|
+
line,
|
|
266
|
+
len(source) - len(source.lstrip()) + 1,
|
|
267
|
+
Extraction.code_block,
|
|
268
|
+
counts,
|
|
269
|
+
confidence=0.75,
|
|
270
|
+
)
|
|
271
|
+
if item:
|
|
272
|
+
claims.append(item)
|
|
273
|
+
for part in _split_shell(command_line):
|
|
274
|
+
tokens = _tokens(part, language)
|
|
275
|
+
assignments: list[tuple[str, str]] = []
|
|
276
|
+
while tokens and _ASSIGNMENT.fullmatch(tokens[0]):
|
|
277
|
+
match = _ASSIGNMENT.fullmatch(tokens.pop(0))
|
|
278
|
+
assert match is not None
|
|
279
|
+
assignments.append((match.group(1), match.group(2)))
|
|
280
|
+
if not _is_project_command(tokens, repo, names, modules):
|
|
281
|
+
continue
|
|
282
|
+
line = token.map[0] + local_line + 1
|
|
283
|
+
for key, _value in assignments:
|
|
284
|
+
item = _claim(
|
|
285
|
+
parsed, ClaimKind.env_var, key, key, line, 1, Extraction.code_block, counts, confidence=0.85
|
|
286
|
+
)
|
|
287
|
+
if item:
|
|
288
|
+
claims.append(item)
|
|
289
|
+
full = " ".join(tokens)
|
|
290
|
+
item = _claim(parsed, ClaimKind.code_command, full, full, line, 1, Extraction.code_block, counts)
|
|
291
|
+
if item:
|
|
292
|
+
claims.append(item)
|
|
293
|
+
wrapper = tokens[0].casefold()
|
|
294
|
+
if wrapper in {"npm", "pnpm", "yarn", "make", "just"}:
|
|
295
|
+
target_index = COMMAND_TARGET_OFFSET if wrapper == "npm" else 1
|
|
296
|
+
if len(tokens) > target_index:
|
|
297
|
+
target_kind = (
|
|
298
|
+
ClaimKind.package_script if wrapper in {"npm", "pnpm", "yarn"} else ClaimKind.make_target
|
|
299
|
+
)
|
|
300
|
+
target_claim = _claim(
|
|
301
|
+
parsed,
|
|
302
|
+
target_kind,
|
|
303
|
+
tokens[target_index],
|
|
304
|
+
" ".join(tokens[: target_index + 1]),
|
|
305
|
+
line,
|
|
306
|
+
1,
|
|
307
|
+
Extraction.code_block,
|
|
308
|
+
counts,
|
|
309
|
+
)
|
|
310
|
+
if target_claim:
|
|
311
|
+
claims.append(target_claim)
|
|
312
|
+
positional: list[str] = []
|
|
313
|
+
positional_seen = False
|
|
314
|
+
command_start = 1
|
|
315
|
+
program = tokens[0].casefold()
|
|
316
|
+
if (
|
|
317
|
+
program in {"python", "python3", "py"}
|
|
318
|
+
and len(tokens) >= MIN_MODULE_COMMAND_TOKENS
|
|
319
|
+
and tokens[1] == "-m"
|
|
320
|
+
) or (program in {"uv", "poetry"} and len(tokens) >= MIN_MODULE_COMMAND_TOKENS and tokens[1] == "run"):
|
|
321
|
+
command_start = WRAPPED_COMMAND_OFFSET
|
|
322
|
+
elif program == "npx" and len(tokens) >= MIN_COMMAND_ARGUMENTS:
|
|
323
|
+
command_start = MIN_COMMAND_ARGUMENTS
|
|
324
|
+
elif program == "npm" and len(tokens) >= MIN_MODULE_COMMAND_TOKENS and tokens[1] == "run":
|
|
325
|
+
command_start = WRAPPED_COMMAND_OFFSET
|
|
326
|
+
elif program in {"pnpm", "yarn", "make", "just"} and len(tokens) >= MIN_COMMAND_ARGUMENTS:
|
|
327
|
+
command_start = MIN_COMMAND_ARGUMENTS
|
|
328
|
+
index = command_start
|
|
329
|
+
while index < len(tokens):
|
|
330
|
+
value = tokens[index]
|
|
331
|
+
if value == "--":
|
|
332
|
+
break
|
|
333
|
+
if value.startswith("-"):
|
|
334
|
+
flag = value.split("=", 1)[0]
|
|
335
|
+
item = _claim(
|
|
336
|
+
parsed,
|
|
337
|
+
ClaimKind.cli_flag,
|
|
338
|
+
flag,
|
|
339
|
+
value,
|
|
340
|
+
line,
|
|
341
|
+
1,
|
|
342
|
+
Extraction.code_block,
|
|
343
|
+
counts,
|
|
344
|
+
qualifier=positional[-1] if positional else None,
|
|
345
|
+
)
|
|
346
|
+
if item:
|
|
347
|
+
claims.append(item)
|
|
348
|
+
if "=" not in value and index + 1 < len(tokens) and not tokens[index + 1].startswith("-"):
|
|
349
|
+
index += 1
|
|
350
|
+
elif not positional_seen and value not in {"run", "exec"}:
|
|
351
|
+
positional_seen = True
|
|
352
|
+
# Paths, URLs and numeric arguments are not subcommand names.
|
|
353
|
+
# Keep them in the complete command for separate validation.
|
|
354
|
+
if re.fullmatch(r"[A-Za-z][\w-]*", value):
|
|
355
|
+
positional.append(value)
|
|
356
|
+
item = _claim(
|
|
357
|
+
parsed, ClaimKind.cli_command, value, value, line, 1, Extraction.code_block, counts
|
|
358
|
+
)
|
|
359
|
+
if item:
|
|
360
|
+
claims.append(item)
|
|
361
|
+
index += 1
|
|
362
|
+
return claims
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _config_claims(parsed: ParsedDoc, token: Token, language: str, counts: ClaimCounter) -> list[Claim]:
|
|
366
|
+
if token.map is None:
|
|
367
|
+
return []
|
|
368
|
+
section = parsed.line_to_section.get(token.map[0] + 1)
|
|
369
|
+
info = token.info.casefold()
|
|
370
|
+
filename_hint = bool(re.search(r"(?:title|filename|file)=['\"]?[^\s'\"]+\.(?:toml|json|ya?ml)", info))
|
|
371
|
+
if not filename_hint and (
|
|
372
|
+
section is None
|
|
373
|
+
or not any(word in heading.casefold() for heading in section.heading_path for word in ("config", "setting"))
|
|
374
|
+
):
|
|
375
|
+
return []
|
|
376
|
+
try:
|
|
377
|
+
if language == "json":
|
|
378
|
+
data = json.loads(token.content)
|
|
379
|
+
elif language == "toml":
|
|
380
|
+
data = tomllib.loads(token.content)
|
|
381
|
+
else:
|
|
382
|
+
data = _simple_yaml(token.content)
|
|
383
|
+
except (ValueError, tomllib.TOMLDecodeError):
|
|
384
|
+
return []
|
|
385
|
+
line_start = token.map[0] + 2
|
|
386
|
+
document_lines = parsed.text.splitlines()
|
|
387
|
+
config_lines = token.content.splitlines()
|
|
388
|
+
prose_default = any(
|
|
389
|
+
"default" in line.casefold() for line in document_lines[max(0, token.map[0] - 2) : token.map[0]]
|
|
390
|
+
)
|
|
391
|
+
claims: list[Claim] = []
|
|
392
|
+
for dotted, line, value in _flatten_config(data, config_lines, line_start):
|
|
393
|
+
item = _claim(
|
|
394
|
+
parsed,
|
|
395
|
+
ClaimKind.config_key,
|
|
396
|
+
dotted,
|
|
397
|
+
dotted,
|
|
398
|
+
line,
|
|
399
|
+
1,
|
|
400
|
+
Extraction.code_block,
|
|
401
|
+
counts,
|
|
402
|
+
expected_default=str(value) if prose_default else None,
|
|
403
|
+
)
|
|
404
|
+
if item:
|
|
405
|
+
claims.append(item)
|
|
406
|
+
return claims
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _simple_yaml(text: str) -> dict[str, Any]:
|
|
410
|
+
"""Read ordinary key/value YAML indentation without tags, aliases, or constructors."""
|
|
411
|
+
root: dict[str, Any] = {}
|
|
412
|
+
parents: list[tuple[int, dict[str, Any]]] = [(-1, root)]
|
|
413
|
+
for line in text.splitlines():
|
|
414
|
+
match = re.match(r"^(\s*)([A-Za-z_][\w-]*):(?:\s*(.*))?$", line)
|
|
415
|
+
if not match:
|
|
416
|
+
continue
|
|
417
|
+
indent, key, value = len(match.group(1)), match.group(2), match.group(3) or ""
|
|
418
|
+
while parents and indent <= parents[-1][0]:
|
|
419
|
+
parents.pop()
|
|
420
|
+
container = parents[-1][1] if parents else root
|
|
421
|
+
if value.strip():
|
|
422
|
+
container[key] = value.strip().strip("'\"")
|
|
423
|
+
else:
|
|
424
|
+
child: dict[str, Any] = {}
|
|
425
|
+
container[key] = child
|
|
426
|
+
parents.append((indent, child))
|
|
427
|
+
return root
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _flatten_config(data: Any, lines: list[str], first_line: int, prefix: str = "") -> list[tuple[str, int, Any]]:
|
|
431
|
+
result: list[tuple[str, int, Any]] = []
|
|
432
|
+
if not isinstance(data, dict):
|
|
433
|
+
return result
|
|
434
|
+
for key, value in data.items():
|
|
435
|
+
dotted = f"{prefix}.{key}" if prefix else str(key)
|
|
436
|
+
line = next(
|
|
437
|
+
(first_line + i for i, source in enumerate(lines) if re.match(rf"\s*{re.escape(str(key))}\s*[:=]", source)),
|
|
438
|
+
first_line,
|
|
439
|
+
)
|
|
440
|
+
if isinstance(value, dict):
|
|
441
|
+
result.extend(_flatten_config(value, lines, first_line, dotted))
|
|
442
|
+
else:
|
|
443
|
+
result.append((dotted, line, value))
|
|
444
|
+
return result
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _dotenv_claims(parsed: ParsedDoc, token: Token, counts: ClaimCounter) -> list[Claim]:
|
|
448
|
+
if token.map is None:
|
|
449
|
+
return []
|
|
450
|
+
result: list[Claim] = []
|
|
451
|
+
for offset, line in enumerate(token.content.splitlines()):
|
|
452
|
+
match = _ASSIGNMENT.fullmatch(line.strip())
|
|
453
|
+
if not match:
|
|
454
|
+
continue
|
|
455
|
+
item = _claim(
|
|
456
|
+
parsed,
|
|
457
|
+
ClaimKind.env_var,
|
|
458
|
+
match.group(1),
|
|
459
|
+
match.group(1),
|
|
460
|
+
token.map[0] + offset + 2,
|
|
461
|
+
1,
|
|
462
|
+
Extraction.code_block,
|
|
463
|
+
counts,
|
|
464
|
+
)
|
|
465
|
+
if item:
|
|
466
|
+
result.append(item)
|
|
467
|
+
return result
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _http_claims(parsed: ParsedDoc, token: Token, counts: ClaimCounter, language: str) -> list[Claim]:
|
|
471
|
+
if token.map is None:
|
|
472
|
+
return []
|
|
473
|
+
result: list[Claim] = []
|
|
474
|
+
pending_method = "GET"
|
|
475
|
+
for offset, line in enumerate(token.content.splitlines()):
|
|
476
|
+
stripped = line.strip()
|
|
477
|
+
method = re.search(r"\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(https?://[^\s]+|/[^\s]+)", stripped, re.I)
|
|
478
|
+
curl = _URL.search(stripped) if language in _SHELL_LANGS else None
|
|
479
|
+
curl_method = _CURL_METHOD.search(stripped) if curl else None
|
|
480
|
+
if method:
|
|
481
|
+
pending_method, path = method.group(1).upper(), method.group(2)
|
|
482
|
+
if path.startswith(("http://", "https://")):
|
|
483
|
+
path = urlsplit(path).path or "/"
|
|
484
|
+
elif curl:
|
|
485
|
+
pending_method = curl_method.group(1).upper() if curl_method else "GET"
|
|
486
|
+
path = urlsplit(curl.group(0)).path or "/"
|
|
487
|
+
else:
|
|
488
|
+
continue
|
|
489
|
+
item = _claim(
|
|
490
|
+
parsed,
|
|
491
|
+
ClaimKind.endpoint,
|
|
492
|
+
f"{pending_method} {path}",
|
|
493
|
+
stripped,
|
|
494
|
+
token.map[0] + offset + 2,
|
|
495
|
+
1,
|
|
496
|
+
Extraction.code_block,
|
|
497
|
+
counts,
|
|
498
|
+
)
|
|
499
|
+
if item:
|
|
500
|
+
result.append(item)
|
|
501
|
+
return result
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _import_claims( # noqa: PLR0912
|
|
505
|
+
parsed: ParsedDoc, token: Token, language: str, modules: set[str], counts: ClaimCounter
|
|
506
|
+
) -> list[Claim]:
|
|
507
|
+
if token.map is None:
|
|
508
|
+
return []
|
|
509
|
+
imports: set[str] = set()
|
|
510
|
+
claims = []
|
|
511
|
+
if language in {"python", "py"}:
|
|
512
|
+
try:
|
|
513
|
+
tree = ast.parse(token.content)
|
|
514
|
+
except SyntaxError:
|
|
515
|
+
return []
|
|
516
|
+
for node in ast.walk(tree):
|
|
517
|
+
if isinstance(node, ast.ImportFrom) and node.module and node.module.split(".", 1)[0] in modules:
|
|
518
|
+
for alias in node.names:
|
|
519
|
+
name = alias.asname or alias.name
|
|
520
|
+
imports.add(name)
|
|
521
|
+
item = _claim(
|
|
522
|
+
parsed,
|
|
523
|
+
ClaimKind.symbol,
|
|
524
|
+
name,
|
|
525
|
+
node.module + "." + alias.name,
|
|
526
|
+
token.map[0] + node.lineno + 1,
|
|
527
|
+
1,
|
|
528
|
+
Extraction.code_block,
|
|
529
|
+
counts,
|
|
530
|
+
)
|
|
531
|
+
if item:
|
|
532
|
+
claims.append(item)
|
|
533
|
+
else:
|
|
534
|
+
for match in re.finditer(r"import\s+(?:\{([^}]+)\}\s+from\s+)?['\"]([^'\"]+)['\"]", token.content):
|
|
535
|
+
package = match.group(2).split("/", 1)[0]
|
|
536
|
+
if package in modules:
|
|
537
|
+
imports.update(name.strip().split(" as ")[-1] for name in (match.group(1) or package).split(","))
|
|
538
|
+
for imported in (match.group(1) or package).split(","):
|
|
539
|
+
symbol = imported.strip().split(" as ")[0].strip()
|
|
540
|
+
if symbol:
|
|
541
|
+
item = _claim(
|
|
542
|
+
parsed,
|
|
543
|
+
ClaimKind.symbol,
|
|
544
|
+
symbol,
|
|
545
|
+
symbol,
|
|
546
|
+
token.map[0] + 2 + token.content.count("\n", 0, match.start()),
|
|
547
|
+
1,
|
|
548
|
+
Extraction.code_block,
|
|
549
|
+
counts,
|
|
550
|
+
)
|
|
551
|
+
if item:
|
|
552
|
+
claims.append(item)
|
|
553
|
+
for name in imports:
|
|
554
|
+
for match in re.finditer(rf"\b{re.escape(name)}(?:\.([A-Za-z_]\w*))?\s*\(", token.content):
|
|
555
|
+
value = name + ("." + match.group(1) if match.group(1) else "")
|
|
556
|
+
line = token.map[0] + token.content.count("\n", 0, match.start()) + 2
|
|
557
|
+
item = _claim(
|
|
558
|
+
parsed, ClaimKind.symbol, value, match.group(0).strip(), line, 1, Extraction.code_block, counts
|
|
559
|
+
)
|
|
560
|
+
if item:
|
|
561
|
+
claims.append(item)
|
|
562
|
+
return claims
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def extract_block_claims(
|
|
566
|
+
parsed: ParsedDoc, repo: Path, *, command_context: tuple[set[str], set[str]] | None = None
|
|
567
|
+
) -> BlockExtractionResult:
|
|
568
|
+
"""Extract claims using only fenced text and repository metadata."""
|
|
569
|
+
names, modules = command_context if command_context is not None else project_command_names(repo)
|
|
570
|
+
counts: ClaimCounter = Counter()
|
|
571
|
+
claims = []
|
|
572
|
+
for token in parsed.tokens:
|
|
573
|
+
if token.type != "fence":
|
|
574
|
+
continue
|
|
575
|
+
language = _block_type(token)
|
|
576
|
+
if language in _SHELL_LANGS:
|
|
577
|
+
claims.extend(_shell_block(parsed, token, language, repo, names, modules, counts))
|
|
578
|
+
if "curl" in token.content:
|
|
579
|
+
claims.extend(_http_claims(parsed, token, counts, language))
|
|
580
|
+
elif language == "http":
|
|
581
|
+
claims.extend(_http_claims(parsed, token, counts, language))
|
|
582
|
+
elif language in {"env", "dotenv"}:
|
|
583
|
+
claims.extend(_dotenv_claims(parsed, token, counts))
|
|
584
|
+
elif language in {"json", "yaml", "yml", "toml"}:
|
|
585
|
+
claims.extend(_config_claims(parsed, token, language, counts))
|
|
586
|
+
elif language in {"python", "py", "js", "javascript", "ts", "typescript"}:
|
|
587
|
+
claims.extend(_import_claims(parsed, token, language, modules, counts))
|
|
588
|
+
current = []
|
|
589
|
+
historical = 0
|
|
590
|
+
for claim in claims:
|
|
591
|
+
section = parsed.line_to_section.get(claim.line)
|
|
592
|
+
if section is not None and is_historical_span(section, claim.line):
|
|
593
|
+
historical += 1
|
|
594
|
+
else:
|
|
595
|
+
current.append(claim)
|
|
596
|
+
return BlockExtractionResult(current, historical)
|