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,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)