docs-drift 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. docs_drift/__init__.py +7 -0
  2. docs_drift/analysis.py +52 -0
  3. docs_drift/baseline.py +70 -0
  4. docs_drift/cli.py +871 -0
  5. docs_drift/collaboration.py +282 -0
  6. docs_drift/config.py +113 -0
  7. docs_drift/docs/__init__.py +1 -0
  8. docs_drift/docs/defaults.py +250 -0
  9. docs_drift/docs/discover.py +172 -0
  10. docs_drift/docs/extract_blocks.py +596 -0
  11. docs_drift/docs/extract_inline.py +561 -0
  12. docs_drift/docs/pipeline.py +234 -0
  13. docs_drift/docs/preprocess.py +71 -0
  14. docs_drift/docs/sections.py +247 -0
  15. docs_drift/errors.py +119 -0
  16. docs_drift/fix/__init__.py +1 -0
  17. docs_drift/fix/edits.py +124 -0
  18. docs_drift/fix/model.py +139 -0
  19. docs_drift/fix/rules.py +232 -0
  20. docs_drift/git_safety.py +35 -0
  21. docs_drift/graph/queries/evidence_path.cypher +6 -0
  22. docs_drift/history.py +412 -0
  23. docs_drift/history_cache.py +62 -0
  24. docs_drift/ids.py +50 -0
  25. docs_drift/index/__init__.py +211 -0
  26. docs_drift/index/base.py +239 -0
  27. docs_drift/index/config.py +185 -0
  28. docs_drift/index/js_cli.py +211 -0
  29. docs_drift/index/js_routes.py +68 -0
  30. docs_drift/index/js_symbols.py +219 -0
  31. docs_drift/index/python_cli.py +370 -0
  32. docs_drift/index/python_routes.py +102 -0
  33. docs_drift/index/python_source.py +20 -0
  34. docs_drift/index/python_symbols.py +335 -0
  35. docs_drift/index/queries/javascript.scm +4 -0
  36. docs_drift/index/queries/typescript.scm +4 -0
  37. docs_drift/index/routes_common.py +16 -0
  38. docs_drift/index/tasks.py +131 -0
  39. docs_drift/index/ts_parser.py +20 -0
  40. docs_drift/limits.py +18 -0
  41. docs_drift/llm/__init__.py +7 -0
  42. docs_drift/llm/candidates.py +81 -0
  43. docs_drift/llm/client.py +185 -0
  44. docs_drift/llm/pricing.py +54 -0
  45. docs_drift/llm/privacy.py +63 -0
  46. docs_drift/llm/prompts/rewrite_v1.md +9 -0
  47. docs_drift/llm/prompts/verify_v1.md +12 -0
  48. docs_drift/llm/prompts.py +28 -0
  49. docs_drift/llm/retrieve.py +60 -0
  50. docs_drift/llm/types.py +68 -0
  51. docs_drift/llm/verify.py +179 -0
  52. docs_drift/lsp.py +199 -0
  53. docs_drift/match/__init__.py +5 -0
  54. docs_drift/match/commands.py +201 -0
  55. docs_drift/match/defaults.py +79 -0
  56. docs_drift/match/engine.py +319 -0
  57. docs_drift/match/rename.py +69 -0
  58. docs_drift/models.py +249 -0
  59. docs_drift/ownership.py +70 -0
  60. docs_drift/policy.py +81 -0
  61. docs_drift/portfolio.py +147 -0
  62. docs_drift/py.typed +0 -0
  63. docs_drift/report/__init__.py +38 -0
  64. docs_drift/report/common.py +31 -0
  65. docs_drift/report/github.py +22 -0
  66. docs_drift/report/html.py +106 -0
  67. docs_drift/report/json.py +17 -0
  68. docs_drift/report/junit.py +30 -0
  69. docs_drift/report/markdown.py +58 -0
  70. docs_drift/report/partials/body.html +19 -0
  71. docs_drift/report/partials/report.css +12 -0
  72. docs_drift/report/partials/script.js +95 -0
  73. docs_drift/report/portfolio.html +14 -0
  74. docs_drift/report/report.html +8 -0
  75. docs_drift/report/sarif.py +68 -0
  76. docs_drift/report/terminal.py +90 -0
  77. docs_drift/report/vendor/CYTOSCAPE-LICENSE +19 -0
  78. docs_drift/report/vendor/cytoscape.min.js +32 -0
  79. docs_drift/report/workflow.html +12 -0
  80. docs_drift/review.py +113 -0
  81. docs_drift/safe_files.py +19 -0
  82. docs_drift/scanning.py +202 -0
  83. docs_drift/schemas/SARIF-LICENSE.md +7 -0
  84. docs_drift/schemas/SARIF-NOTICES.txt +3 -0
  85. docs_drift/schemas/sarif-schema-2.1.0.json +3389 -0
  86. docs_drift/schemas/scan-result.v1.json +1024 -0
  87. docs_drift/setup.py +130 -0
  88. docs_drift/snapshots.py +100 -0
  89. docs_drift/support.py +31 -0
  90. docs_drift/watch.py +157 -0
  91. docs_drift/web/__init__.py +1 -0
  92. docs_drift/web/__main__.py +23 -0
  93. docs_drift/web/app.py +289 -0
  94. docs_drift/web/cleanup.py +23 -0
  95. docs_drift/web/examples/clean-repo.json +646 -0
  96. docs_drift/web/examples/fastapi-svc.json +723 -0
  97. docs_drift/web/examples/files-to-prompt.json +3366 -0
  98. docs_drift/web/examples/judge-fixture.json +226 -0
  99. docs_drift/web/examples/manifest.json +89 -0
  100. docs_drift/web/examples/node-tool.json +823 -0
  101. docs_drift/web/examples/py-cli.json +1267 -0
  102. docs_drift/web/examples/rich-cli-reviewed.json +7855 -0
  103. docs_drift/web/examples/rich-cli.json +8010 -0
  104. docs_drift/web/examples/verification.json +16 -0
  105. docs_drift/web/git.py +274 -0
  106. docs_drift/web/jobs.py +352 -0
  107. docs_drift/web/settings.py +50 -0
  108. docs_drift/web/static/demo.css +37 -0
  109. docs_drift/web/static/demo.js +184 -0
  110. docs_drift/web/static/evidence/correction-verification.json +28 -0
  111. docs_drift/web/static/evidence/flow-highlights-2026-10-22.png +0 -0
  112. docs_drift/web/static/evidence/fresh-scan.json +7855 -0
  113. docs_drift/web/static/evidence/review.json +44 -0
  114. docs_drift/web/static/evidence/rich-cli-correction.patch +20 -0
  115. docs_drift/web/static/evidence/runtime.json +26 -0
  116. docs_drift/web/static/evidence/saved-scan.json +8010 -0
  117. docs_drift/web/static/evidence/usage-card-supplied.png +0 -0
  118. docs_drift/web/templates/base.html +34 -0
  119. docs_drift/web/templates/case.html +13 -0
  120. docs_drift/web/templates/landing.html +90 -0
  121. docs_drift/web/templates/scan.html +2 -0
  122. docs_drift/web/templates/voice.html +1 -0
  123. docs_drift/web/validation.py +47 -0
  124. docs_drift/web/worker.py +152 -0
  125. docs_drift/workflow_cli.py +96 -0
  126. docs_drift/workspaces.py +128 -0
  127. docs_drift-0.1.0.dist-info/METADATA +331 -0
  128. docs_drift-0.1.0.dist-info/RECORD +131 -0
  129. docs_drift-0.1.0.dist-info/WHEEL +4 -0
  130. docs_drift-0.1.0.dist-info/entry_points.txt +2 -0
  131. docs_drift-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)