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,561 @@
|
|
|
1
|
+
"""Conservative extraction of checkable claims from prose and Markdown tokens."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from markdown_it.token import Token
|
|
8
|
+
|
|
9
|
+
from docs_drift.docs.sections import ParsedDoc, is_historical_span
|
|
10
|
+
from docs_drift.ids import make_claim_id
|
|
11
|
+
from docs_drift.models import Claim, ClaimKind, Extraction
|
|
12
|
+
|
|
13
|
+
# Require all-caps separators or a known suffix for environment names; the stoplist
|
|
14
|
+
# removes common headings and HTTP vocabulary that happen to look capitalized.
|
|
15
|
+
_ENV_STOP = {"HTTP", "JSON", "API", "README", "TODO", "GET", "POST", "NOTE", "MIT"}
|
|
16
|
+
_ENV_SUFFIX = ("URL", "KEY", "TOKEN", "PATH", "HOST", "PORT", "DIR")
|
|
17
|
+
MIN_SUBCOMMAND_HEADING_LEVEL = 2
|
|
18
|
+
_ENV_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$|^[A-Z][A-Z0-9]*(?:URL|KEY|TOKEN|PATH|HOST|PORT|DIR)$")
|
|
19
|
+
# Flag assignments are accepted so the value can be separated as example data.
|
|
20
|
+
_FLAG = re.compile(r"^--?[A-Za-z][\w-]*(?:=.*)?$")
|
|
21
|
+
# Endpoints are limited to common HTTP methods and absolute paths; parameter
|
|
22
|
+
# names are normalized separately so `/users/{id}` matches `/users/:user_id`.
|
|
23
|
+
_ENDPOINT = re.compile(r"^(?:(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+)?(/[^\s]*)$", re.IGNORECASE)
|
|
24
|
+
_PATH_PARAM = re.compile(r"\{[^}/]+\}|:[A-Za-z_]\w*|<[A-Za-z_]\w*>")
|
|
25
|
+
# This short extension set avoids treating arbitrary dotted prose as a file path.
|
|
26
|
+
_FILE_EXT = re.compile(r"\.(?:py|pyi|js|jsx|ts|tsx|json|toml|ya?ml|mdx?|rst|txt|html?|env)(?:$|[?#])", re.IGNORECASE)
|
|
27
|
+
_IDENTIFIER = re.compile(r"^(?:[A-Za-z_]\w*(?:\(\))?|[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)$")
|
|
28
|
+
_CAMEL_OR_SNAKE = re.compile(r"^(?:[a-z]+(?:_[a-z0-9]+)+|[A-Z][a-zA-Z0-9]+)$")
|
|
29
|
+
_SENTENCE = re.compile(r".+?(?:[.!?](?=\s|$)|$)")
|
|
30
|
+
_DEFAULT = re.compile(
|
|
31
|
+
r"\bdefaults?\s+to\s+`?(?P<to>[^``,;)]+)`?|"
|
|
32
|
+
r"\bdefault\s*(?::|is)\s*`?(?P<colon>[^``,;)]+)`?|"
|
|
33
|
+
r"\(\s*default\s+`?(?P<paren>[^`)]+)`?\s*\)",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
_SUBCOMMAND = re.compile(r"\b(?:the\s+)?`?([a-z][\w-]*)`?\s+(?:sub)?command\b", re.IGNORECASE)
|
|
37
|
+
_COMMAND_NAME = re.compile(r"`([a-z][\w-]*)`\s+subcommand\b", re.IGNORECASE)
|
|
38
|
+
_TOOL_COMMAND = re.compile(
|
|
39
|
+
r"^(?P<tool>npm\s+run|pnpm|yarn|make|just|uv\s+run|poetry\s+run)\s+(?P<target>[\w:.-]+)(?:\s+.*)?$", re.IGNORECASE
|
|
40
|
+
)
|
|
41
|
+
_NPX_COMMAND = re.compile(r"^npx\s+[^\s]+(?:\s+.*)?$", re.IGNORECASE)
|
|
42
|
+
_WRAPPED_ENV = re.compile(
|
|
43
|
+
r"^(?:process\.env\.(?P<process>[A-Za-z_]\w*)|os\.environ\[\s*['\"](?P<environ>[A-Za-z_]\w*)['\"]\s*\]|os\.getenv\(\s*['\"](?P<getenv>[A-Za-z_]\w*)['\"]\s*\)|\$(?:\{(?P<braced>[A-Za-z_]\w*)\}|(?P<shell>[A-Za-z_]\w*)))$"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ExtractionResult:
|
|
49
|
+
"""Claims plus the number of candidate mentions suppressed as historical."""
|
|
50
|
+
|
|
51
|
+
claims: list[Claim]
|
|
52
|
+
historical_skipped: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class _Candidate:
|
|
57
|
+
kind: ClaimKind
|
|
58
|
+
value: str
|
|
59
|
+
line: int
|
|
60
|
+
column: int
|
|
61
|
+
raw_text: str
|
|
62
|
+
extraction: Extraction
|
|
63
|
+
confidence: float
|
|
64
|
+
expected_default: str | None = None
|
|
65
|
+
qualifier: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _environment(value: str) -> str | None:
|
|
69
|
+
if re.fullmatch(r"[A-Z][A-Z0-9_]+=\S+", value):
|
|
70
|
+
value = value.partition("=")[0]
|
|
71
|
+
wrapped = _WRAPPED_ENV.fullmatch(value)
|
|
72
|
+
if wrapped:
|
|
73
|
+
return next(part for part in wrapped.groupdict().values() if part is not None)
|
|
74
|
+
if value in _ENV_STOP:
|
|
75
|
+
return None
|
|
76
|
+
if value in _ENV_SUFFIX:
|
|
77
|
+
return value
|
|
78
|
+
if not _ENV_PATTERN.fullmatch(value):
|
|
79
|
+
return None
|
|
80
|
+
# Exclude unseparated acronym-like words; suffix recognition is handled above.
|
|
81
|
+
if "_" not in value and not value.endswith(_ENV_SUFFIX):
|
|
82
|
+
return None
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _explicit_api_reference(value: str, sentence: str) -> bool:
|
|
87
|
+
"""Disambiguate dotted API names using their own local grammatical context."""
|
|
88
|
+
if "." not in value or not _IDENTIFIER.fullmatch(value):
|
|
89
|
+
return False
|
|
90
|
+
quoted = r"[`'\"]*" + re.escape(value) + r"[`'\"]*"
|
|
91
|
+
role = r"(?:module|attribute|property|object|instance)"
|
|
92
|
+
return bool(
|
|
93
|
+
re.search(r"\b" + role + r"\s+(?:named\s+)?" + quoted, sentence, re.I)
|
|
94
|
+
or re.search(quoted + r"\s+" + role + r"\b", sentence, re.I)
|
|
95
|
+
or re.search(
|
|
96
|
+
r"\b(?:functions?|methods?)\s+(?:provided\s+by|available\s+on|in|on|of|from)\s+" + quoted,
|
|
97
|
+
sentence,
|
|
98
|
+
re.I,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _classify( # noqa: PLR0911
|
|
104
|
+
value: str, *, api_reference: bool = False, sentence: str = ""
|
|
105
|
+
) -> tuple[ClaimKind, str, float] | None:
|
|
106
|
+
"""Prefer narrow lexical shapes; prose context only upgrades clear identifiers to symbols."""
|
|
107
|
+
command = _TOOL_COMMAND.fullmatch(value)
|
|
108
|
+
if command:
|
|
109
|
+
tool = command.group("tool").casefold().split()[0]
|
|
110
|
+
target = command.group("target")
|
|
111
|
+
kind = ClaimKind.make_target if tool in {"make", "just"} else ClaimKind.package_script
|
|
112
|
+
return kind, target, 0.9
|
|
113
|
+
if _NPX_COMMAND.fullmatch(value):
|
|
114
|
+
# Preserve the full invocation so command-specific flags remain attached to their owning tool.
|
|
115
|
+
return ClaimKind.code_command, value, 0.8
|
|
116
|
+
env = _environment(value)
|
|
117
|
+
if env:
|
|
118
|
+
# The explicit 'set' extractor already retains its name/value pair.
|
|
119
|
+
if "=" in value:
|
|
120
|
+
return (
|
|
121
|
+
None
|
|
122
|
+
if re.search(r"\bset\s+`" + re.escape(value) + r"`", sentence, re.I)
|
|
123
|
+
else (ClaimKind.env_var, env, 0.85)
|
|
124
|
+
)
|
|
125
|
+
return ClaimKind.env_var, env, 0.95 if env == value else 0.9
|
|
126
|
+
if re.search(r"\bsubcommand\b", sentence, re.IGNORECASE) and re.fullmatch(r"[a-z][\w-]*", value):
|
|
127
|
+
return ClaimKind.cli_command, value, 0.8
|
|
128
|
+
endpoint = _ENDPOINT.fullmatch(value)
|
|
129
|
+
if endpoint:
|
|
130
|
+
method, path = endpoint.groups()
|
|
131
|
+
normalized = _PATH_PARAM.sub("{param}", path)
|
|
132
|
+
return ClaimKind.endpoint, f"{method.upper()} {normalized}" if method else normalized, 0.95
|
|
133
|
+
if _FLAG.fullmatch(value):
|
|
134
|
+
name = value.split("=", 1)[0]
|
|
135
|
+
# A short single-dash option is weaker because prose often uses hyphenated examples.
|
|
136
|
+
return ClaimKind.cli_flag, name, 0.9 if name.startswith("--") else 0.65
|
|
137
|
+
if value.startswith(("http://", "https://", "www.", "#!")):
|
|
138
|
+
return None
|
|
139
|
+
return _path_or_symbol(value, api_reference=api_reference, sentence=sentence)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _path_or_symbol(value: str, *, api_reference: bool, sentence: str) -> tuple[ClaimKind, str, float] | None:
|
|
143
|
+
if _explicit_api_reference(value, sentence):
|
|
144
|
+
return ClaimKind.symbol, value, 0.8
|
|
145
|
+
if re.search(r"\b(?:setting|config(?:uration)?\s+key|field)\b", sentence, re.IGNORECASE) and re.fullmatch(
|
|
146
|
+
r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*", value
|
|
147
|
+
):
|
|
148
|
+
return ClaimKind.config_key, value, 0.75
|
|
149
|
+
call = re.fullmatch(r"([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\(.*\)", value)
|
|
150
|
+
if call:
|
|
151
|
+
return ClaimKind.symbol, call[1], 0.8 if api_reference else 0.72
|
|
152
|
+
# Known source and documentation extensions distinguish paths from dotted symbols.
|
|
153
|
+
file_value = re.sub(r":\d+(?::\d+)?$", "", value)
|
|
154
|
+
if (
|
|
155
|
+
not re.search(r"\s|[*?]|^\.[A-Za-z]+$", value)
|
|
156
|
+
and not re.search(r"\b(?:never commit|do not commit|glob|ignore)\b", sentence, re.I)
|
|
157
|
+
and (value.startswith(".env.") or "/" in value or _FILE_EXT.search(file_value))
|
|
158
|
+
and not value.startswith(("http://", "https://"))
|
|
159
|
+
):
|
|
160
|
+
return ClaimKind.file_path, file_value, 0.88
|
|
161
|
+
# A code command's own target is useful; flags belonging to unrelated tools are not.
|
|
162
|
+
symbol_context = api_reference or re.search(r"\b(?:function|class|method|call|returns?)\b", sentence, re.IGNORECASE)
|
|
163
|
+
# Factory context establishes the role independently of the identifier's verb prefix.
|
|
164
|
+
application_factory = re.search(
|
|
165
|
+
r"\b(?:create|build|construct)\s+the\s+application\s+with\b", sentence, re.IGNORECASE
|
|
166
|
+
)
|
|
167
|
+
if _IDENTIFIER.fullmatch(value) and (
|
|
168
|
+
"(" in value
|
|
169
|
+
or "." in value
|
|
170
|
+
or (symbol_context and _CAMEL_OR_SNAKE.fullmatch(value))
|
|
171
|
+
or (application_factory and _CAMEL_OR_SNAKE.fullmatch(value))
|
|
172
|
+
):
|
|
173
|
+
return ClaimKind.symbol, value.removesuffix("()"), 0.72 if not api_reference else 0.8
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def path_context(sentence: str, value: str = "") -> str | None:
|
|
178
|
+
"""Retain explicitly illustrative/output paths without asserting checkout existence."""
|
|
179
|
+
if re.match(r"^[A-Za-z][\w+.-]*://|^@(?:[\w.-]+/|/)", value):
|
|
180
|
+
return "non-checkout-path"
|
|
181
|
+
quoted = r"[`'\"]" + re.escape(value) + r"[`'\"]"
|
|
182
|
+
if value and re.search(
|
|
183
|
+
r"\b(?:create|generate|write|save)\s+(?:(?:a|an|the|new)\s+)?"
|
|
184
|
+
r"(?:(?:file|directory|directory tree|folder)\s+)?" + quoted,
|
|
185
|
+
sentence,
|
|
186
|
+
re.I,
|
|
187
|
+
):
|
|
188
|
+
return "non-checkout-path"
|
|
189
|
+
if re.search(
|
|
190
|
+
r"\b(?:generated(?=\s+`)|generated (?:reports?|files?|artifacts?|(?:debug )?logs?)|output (?:files?|paths?)|"
|
|
191
|
+
r"example (?:manifest|filename|path)|placeholder|shorthand|upstream source|external repository)\b",
|
|
192
|
+
sentence,
|
|
193
|
+
re.I,
|
|
194
|
+
):
|
|
195
|
+
return "non-checkout-path"
|
|
196
|
+
if (
|
|
197
|
+
"/" in value
|
|
198
|
+
and not re.search(r"\.[A-Za-z0-9]{1,8}$", value)
|
|
199
|
+
and not re.search(r"\b(?:file|directory|folder|path)\b", sentence, re.I)
|
|
200
|
+
):
|
|
201
|
+
return "ambiguous-path"
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _line_column(text: str, raw: str, cursor: list[int]) -> tuple[int, int]:
|
|
206
|
+
"""Recover positions from source text because markdown-it inline tokens omit maps."""
|
|
207
|
+
needle = f"`{raw}`"
|
|
208
|
+
start = text.find(needle, cursor[0])
|
|
209
|
+
if start < 0:
|
|
210
|
+
start = text.find(raw, cursor[0])
|
|
211
|
+
if start < 0:
|
|
212
|
+
return 1, 1
|
|
213
|
+
cursor[0] = start + len(needle)
|
|
214
|
+
return text.count("\n", 0, start) + 1, start - text.rfind("\n", 0, start)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _sentence_for_line(text: str | list[str], line: int, column: int) -> str:
|
|
218
|
+
lines = text.splitlines() if isinstance(text, str) else text
|
|
219
|
+
if not (1 <= line <= len(lines)):
|
|
220
|
+
return ""
|
|
221
|
+
source = lines[line - 1]
|
|
222
|
+
return next(
|
|
223
|
+
(match.group() for match in _SENTENCE.finditer(source) if match.start() <= column <= match.end()), source
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _qualifier(section_heading: str, sentence: str, heading_level: int) -> str | None:
|
|
228
|
+
"""Use only explicit command labels in the heading or sentence to avoid guessing ownership."""
|
|
229
|
+
heading_match = re.fullmatch(r"\s*([\w-]+)\s*", section_heading)
|
|
230
|
+
if heading_match and heading_level >= MIN_SUBCOMMAND_HEADING_LEVEL:
|
|
231
|
+
return heading_match.group(1)
|
|
232
|
+
match = _SUBCOMMAND.search(sentence)
|
|
233
|
+
if match:
|
|
234
|
+
return match.group(1)
|
|
235
|
+
command = _COMMAND_NAME.search(sentence)
|
|
236
|
+
if command:
|
|
237
|
+
return command.group(1)
|
|
238
|
+
# In compact bullets, an explicit command name directly before "accepts" is still clear ownership.
|
|
239
|
+
accepts = re.search(r"`([a-z][\w-]*)`\s+accepts\b", sentence, re.IGNORECASE)
|
|
240
|
+
return accepts.group(1) if accepts else None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _inline_candidates(parsed: ParsedDoc) -> list[_Candidate]:
|
|
244
|
+
candidates: list[_Candidate] = []
|
|
245
|
+
cursor = [0]
|
|
246
|
+
block_line: int | None = None
|
|
247
|
+
for token in parsed.tokens:
|
|
248
|
+
if token.type == "paragraph_open" and token.map is not None:
|
|
249
|
+
block_line = token.map[0] + 1
|
|
250
|
+
continue
|
|
251
|
+
if token.type == "paragraph_close":
|
|
252
|
+
block_line = None
|
|
253
|
+
continue
|
|
254
|
+
if token.type != "inline" or token.children is None:
|
|
255
|
+
continue
|
|
256
|
+
for child in token.children:
|
|
257
|
+
if child.type != "code_inline":
|
|
258
|
+
continue
|
|
259
|
+
raw = child.content
|
|
260
|
+
line, column = _line_column(parsed.text, raw, cursor)
|
|
261
|
+
if token.map is not None and line < token.map[0] + 1:
|
|
262
|
+
line = token.map[0] + 1
|
|
263
|
+
elif block_line is None:
|
|
264
|
+
continue
|
|
265
|
+
section = parsed.line_to_section.get(line)
|
|
266
|
+
if section is None:
|
|
267
|
+
continue
|
|
268
|
+
sentence = _sentence_for_line(parsed.text, line, column)
|
|
269
|
+
api_reference = any("api reference" in heading.casefold() for heading in section.heading_path)
|
|
270
|
+
kind_and_value = _classify(raw, api_reference=api_reference, sentence=sentence)
|
|
271
|
+
if kind_and_value is None:
|
|
272
|
+
continue
|
|
273
|
+
kind, value, confidence = kind_and_value
|
|
274
|
+
qualifier = (
|
|
275
|
+
_qualifier(section.heading, sentence, section.heading_level) if kind == ClaimKind.cli_flag else None
|
|
276
|
+
)
|
|
277
|
+
if kind == ClaimKind.file_path:
|
|
278
|
+
qualifier = path_context(sentence, value)
|
|
279
|
+
if qualifier is None and line > 1 and parsed.lines[line - 2].rstrip().endswith(":"):
|
|
280
|
+
qualifier = path_context(parsed.lines[line - 2], value)
|
|
281
|
+
candidates.append(
|
|
282
|
+
_Candidate(kind, value, line, column, raw, Extraction.inline_code, confidence, qualifier=qualifier)
|
|
283
|
+
)
|
|
284
|
+
return candidates
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _table_cells(tokens: list[Token]) -> list[tuple[list[tuple[str, int]], list[list[tuple[str, int]]]]]:
|
|
288
|
+
"""Read Markdown table cell inline text and source lines without interpreting HTML."""
|
|
289
|
+
tables = []
|
|
290
|
+
index = 0
|
|
291
|
+
while index < len(tokens):
|
|
292
|
+
if tokens[index].type != "table_open":
|
|
293
|
+
index += 1
|
|
294
|
+
continue
|
|
295
|
+
index += 1
|
|
296
|
+
header: list[tuple[str, int]] = []
|
|
297
|
+
rows: list[list[tuple[str, int]]] = []
|
|
298
|
+
current: list[tuple[str, int]] | None = None
|
|
299
|
+
row_line = 1
|
|
300
|
+
in_head = False
|
|
301
|
+
while index < len(tokens) and tokens[index].type != "table_close":
|
|
302
|
+
token = tokens[index]
|
|
303
|
+
if token.type == "thead_open":
|
|
304
|
+
in_head = True
|
|
305
|
+
elif token.type == "thead_close":
|
|
306
|
+
in_head = False
|
|
307
|
+
elif token.type == "tr_open":
|
|
308
|
+
current = []
|
|
309
|
+
row_line = token.map[0] + 1 if token.map is not None else 1
|
|
310
|
+
elif token.type == "inline" and current is not None:
|
|
311
|
+
line = token.map[0] + 1 if token.map is not None else row_line
|
|
312
|
+
current.append((token.content.strip(), line))
|
|
313
|
+
elif token.type == "tr_close" and current is not None:
|
|
314
|
+
if in_head:
|
|
315
|
+
header = current
|
|
316
|
+
else:
|
|
317
|
+
rows.append(current)
|
|
318
|
+
current = None
|
|
319
|
+
index += 1
|
|
320
|
+
if header:
|
|
321
|
+
tables.append((header, rows))
|
|
322
|
+
index += 1
|
|
323
|
+
return tables
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _table_candidates(parsed: ParsedDoc) -> list[_Candidate]:
|
|
327
|
+
candidates = []
|
|
328
|
+
header_words = re.compile(r"\b(?:flag|option|argument|variable|env|setting|key|default|endpoint)\b", re.IGNORECASE)
|
|
329
|
+
for header, rows in _table_cells(parsed.tokens):
|
|
330
|
+
names = [cell.casefold() for cell, _ in header]
|
|
331
|
+
if not any(header_words.search(cell) for cell in names):
|
|
332
|
+
continue
|
|
333
|
+
# The first column is the documented identifier; only a headed default column is a claimed value.
|
|
334
|
+
default_index = next((i for i, name in enumerate(names) if name.strip() in {"default", "default value"}), None)
|
|
335
|
+
first_heading = names[0] if names else ""
|
|
336
|
+
for row in rows:
|
|
337
|
+
if not row:
|
|
338
|
+
continue
|
|
339
|
+
raw, line = row[0]
|
|
340
|
+
raw = raw.strip("`")
|
|
341
|
+
normalized_value: str | None = None
|
|
342
|
+
section = parsed.line_to_section.get(line)
|
|
343
|
+
if section is None:
|
|
344
|
+
continue
|
|
345
|
+
sentence = " ".join(cell for cell, _ in row)
|
|
346
|
+
if "flag" in first_heading or "option" in first_heading or "argument" in first_heading:
|
|
347
|
+
classified = _classify(raw, sentence=sentence)
|
|
348
|
+
kind = ClaimKind.cli_flag if classified and classified[0] == ClaimKind.cli_flag else None
|
|
349
|
+
elif any(term in first_heading for term in ("env", "variable")):
|
|
350
|
+
env = _environment(raw.strip("`"))
|
|
351
|
+
kind = ClaimKind.env_var if env else None
|
|
352
|
+
raw = raw.strip("`")
|
|
353
|
+
elif "endpoint" in first_heading:
|
|
354
|
+
classified = _classify(raw.strip("`"))
|
|
355
|
+
kind = ClaimKind.endpoint if classified and classified[0] == ClaimKind.endpoint else None
|
|
356
|
+
normalized_value = classified[1] if classified else None
|
|
357
|
+
elif "setting" in first_heading or "key" in first_heading:
|
|
358
|
+
config_key = re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*", raw.strip("`"))
|
|
359
|
+
kind = ClaimKind.config_key if config_key else None
|
|
360
|
+
raw = raw.strip("`")
|
|
361
|
+
else:
|
|
362
|
+
classified = _classify(raw.strip("`"), sentence=sentence)
|
|
363
|
+
kind = classified[0] if classified else None
|
|
364
|
+
if kind is None:
|
|
365
|
+
continue
|
|
366
|
+
value = normalized_value or raw.strip("`")
|
|
367
|
+
default = row[default_index][0].strip() if default_index is not None and default_index < len(row) else None
|
|
368
|
+
if default and default.startswith("`") and not re.fullmatch(r"`[^`]+`", default):
|
|
369
|
+
default = None
|
|
370
|
+
default = default.strip("` ") if default else None
|
|
371
|
+
default = default if default and default.casefold() not in {"none", "n/a", "—", "-"} else None
|
|
372
|
+
candidates.append(_Candidate(kind, value, line, 1, raw, Extraction.table, 0.88, expected_default=default))
|
|
373
|
+
return candidates
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _code_block_candidates(parsed: ParsedDoc) -> list[_Candidate]:
|
|
377
|
+
"""Recognize only known project-command shapes in shell-labeled fences; never run them."""
|
|
378
|
+
candidates = []
|
|
379
|
+
for token in parsed.tokens:
|
|
380
|
+
if token.type != "fence" or token.map is None:
|
|
381
|
+
continue
|
|
382
|
+
language = token.info.strip().casefold().split(maxsplit=1)[0] if token.info.strip() else ""
|
|
383
|
+
if language not in {"sh", "shell", "bash", "zsh", "console"}:
|
|
384
|
+
continue
|
|
385
|
+
for offset, source in enumerate(token.content.splitlines()):
|
|
386
|
+
command = source.strip()
|
|
387
|
+
classified = _classify(command)
|
|
388
|
+
if classified is None:
|
|
389
|
+
continue
|
|
390
|
+
kind, value, confidence = classified
|
|
391
|
+
candidates.append(
|
|
392
|
+
_Candidate(
|
|
393
|
+
kind,
|
|
394
|
+
value,
|
|
395
|
+
token.map[0] + offset + 2,
|
|
396
|
+
len(source) - len(source.lstrip()) + 1,
|
|
397
|
+
command,
|
|
398
|
+
Extraction.code_block,
|
|
399
|
+
confidence,
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
return candidates
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _prose_candidates(parsed: ParsedDoc, candidates: list[_Candidate]) -> list[_Candidate]:
|
|
406
|
+
result = []
|
|
407
|
+
by_line: dict[int, list[_Candidate]] = {}
|
|
408
|
+
for candidate in candidates:
|
|
409
|
+
by_line.setdefault(candidate.line, []).append(candidate)
|
|
410
|
+
for line, source in enumerate(parsed.text.splitlines(), 1):
|
|
411
|
+
section = parsed.line_to_section.get(line)
|
|
412
|
+
if section is None:
|
|
413
|
+
continue
|
|
414
|
+
for match in _DEFAULT.finditer(source):
|
|
415
|
+
value = next(group for group in match.groupdict().values() if group is not None).strip()
|
|
416
|
+
if not value:
|
|
417
|
+
continue # RST double-backtick defaults can otherwise yield a whitespace-only span.
|
|
418
|
+
sentence = next(
|
|
419
|
+
(item for item in _SENTENCE.finditer(source) if item.start() <= match.start() <= item.end()), None
|
|
420
|
+
)
|
|
421
|
+
sentence_start = sentence.start() if sentence else 0
|
|
422
|
+
nearby = [
|
|
423
|
+
candidate
|
|
424
|
+
for candidate in by_line.get(line, [])
|
|
425
|
+
if sentence_start <= candidate.column - 1 <= match.start()
|
|
426
|
+
]
|
|
427
|
+
if not nearby:
|
|
428
|
+
# Defaults without a named claim have weak value but remain useful for downstream linking.
|
|
429
|
+
result.append(
|
|
430
|
+
_Candidate(
|
|
431
|
+
ClaimKind.default_value,
|
|
432
|
+
value,
|
|
433
|
+
line,
|
|
434
|
+
match.start() + 1,
|
|
435
|
+
match.group(),
|
|
436
|
+
Extraction.prose_pattern,
|
|
437
|
+
0.45,
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
else:
|
|
441
|
+
result.append(
|
|
442
|
+
_Candidate(
|
|
443
|
+
ClaimKind.default_value,
|
|
444
|
+
value,
|
|
445
|
+
line,
|
|
446
|
+
match.start() + 1,
|
|
447
|
+
match.group(),
|
|
448
|
+
Extraction.prose_pattern,
|
|
449
|
+
0.5,
|
|
450
|
+
qualifier=nearby[-1].value,
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
for match in re.finditer(r"\bset\s+`([^`=]+)=([^`]+)`(?:\s+to\b)?", source, re.IGNORECASE):
|
|
454
|
+
name, example = match.groups()
|
|
455
|
+
# This targeted pattern preserves the setting name while treating its assignment as an example.
|
|
456
|
+
result.append(
|
|
457
|
+
_Candidate(
|
|
458
|
+
ClaimKind.env_var,
|
|
459
|
+
name,
|
|
460
|
+
line,
|
|
461
|
+
match.start(1) + 1,
|
|
462
|
+
match.group(1),
|
|
463
|
+
Extraction.prose_pattern,
|
|
464
|
+
0.55,
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
result.append(
|
|
468
|
+
_Candidate(
|
|
469
|
+
ClaimKind.default_value,
|
|
470
|
+
example,
|
|
471
|
+
line,
|
|
472
|
+
match.start(2) + 1,
|
|
473
|
+
example,
|
|
474
|
+
Extraction.prose_pattern,
|
|
475
|
+
0.4,
|
|
476
|
+
qualifier=name,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
for match in re.finditer(r"\bset\s+`([^`=]+)`\s+to\s+`([^`]+)`", source, re.IGNORECASE):
|
|
480
|
+
name, example = match.groups()
|
|
481
|
+
# A stated assignment is an example value, so preserve the setting name as its qualifier.
|
|
482
|
+
result.append(
|
|
483
|
+
_Candidate(
|
|
484
|
+
ClaimKind.default_value,
|
|
485
|
+
example,
|
|
486
|
+
line,
|
|
487
|
+
match.start(2) + 1,
|
|
488
|
+
example,
|
|
489
|
+
Extraction.prose_pattern,
|
|
490
|
+
0.4,
|
|
491
|
+
qualifier=name,
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
return result
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def extract_claims(parsed: ParsedDoc) -> ExtractionResult:
|
|
498
|
+
"""Extract high-signal spans first, tables next, then only explicit low-confidence prose forms."""
|
|
499
|
+
candidates = _inline_candidates(parsed)
|
|
500
|
+
# API-reference context is inherited from the section path and checked per span during classification.
|
|
501
|
+
candidates.extend(_table_candidates(parsed))
|
|
502
|
+
candidates.extend(_code_block_candidates(parsed))
|
|
503
|
+
candidates.extend(_prose_candidates(parsed, candidates))
|
|
504
|
+
table_mentions = {
|
|
505
|
+
(candidate.line, candidate.raw_text.strip("`").strip())
|
|
506
|
+
for candidate in candidates
|
|
507
|
+
if candidate.extraction == Extraction.table
|
|
508
|
+
}
|
|
509
|
+
# A table's header supplies stronger kind context than the same first-column inline span.
|
|
510
|
+
candidates = [
|
|
511
|
+
candidate
|
|
512
|
+
for candidate in candidates
|
|
513
|
+
if not (
|
|
514
|
+
candidate.extraction == Extraction.inline_code and (candidate.line, candidate.raw_text) in table_mentions
|
|
515
|
+
)
|
|
516
|
+
]
|
|
517
|
+
selected: list[_Candidate] = []
|
|
518
|
+
skipped = 0
|
|
519
|
+
seen: set[tuple[ClaimKind, str, int, int]] = set()
|
|
520
|
+
for candidate in sorted(candidates, key=lambda item: (item.line, item.column, item.kind.value, item.value)):
|
|
521
|
+
section = parsed.line_to_section.get(candidate.line)
|
|
522
|
+
if section is None:
|
|
523
|
+
continue
|
|
524
|
+
if is_historical_span(section, candidate.line):
|
|
525
|
+
skipped += 1
|
|
526
|
+
continue
|
|
527
|
+
key = (candidate.kind, candidate.value, candidate.line, candidate.column)
|
|
528
|
+
if key in seen:
|
|
529
|
+
continue
|
|
530
|
+
seen.add(key)
|
|
531
|
+
selected.append(candidate)
|
|
532
|
+
occurrences: Counter[tuple[str, ClaimKind, str]] = Counter()
|
|
533
|
+
claims = []
|
|
534
|
+
for candidate in selected:
|
|
535
|
+
section = parsed.line_to_section[candidate.line]
|
|
536
|
+
occurrence_key = (section.section_id, candidate.kind, candidate.value)
|
|
537
|
+
occurrence = occurrences[occurrence_key]
|
|
538
|
+
occurrences[occurrence_key] += 1
|
|
539
|
+
sentence = _sentence_for_line(parsed.lines, candidate.line, candidate.column)
|
|
540
|
+
qualifier = candidate.qualifier
|
|
541
|
+
if qualifier is None and candidate.kind == ClaimKind.cli_flag:
|
|
542
|
+
qualifier = _qualifier(section.heading, sentence, section.heading_level)
|
|
543
|
+
claims.append(
|
|
544
|
+
Claim(
|
|
545
|
+
claim_id=make_claim_id(
|
|
546
|
+
parsed.doc.path, section.heading_path, candidate.kind, candidate.value, occurrence
|
|
547
|
+
),
|
|
548
|
+
kind=candidate.kind,
|
|
549
|
+
raw_text=candidate.raw_text,
|
|
550
|
+
value=candidate.value,
|
|
551
|
+
qualifier=qualifier,
|
|
552
|
+
expected_default=candidate.expected_default,
|
|
553
|
+
doc_path=parsed.doc.path,
|
|
554
|
+
line=candidate.line,
|
|
555
|
+
column=candidate.column,
|
|
556
|
+
section_id=section.section_id,
|
|
557
|
+
extraction=candidate.extraction,
|
|
558
|
+
extraction_confidence=candidate.confidence,
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
return ExtractionResult(claims, skipped)
|