primarysource 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 (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
@@ -0,0 +1,37 @@
1
+ """What a fetched-document collection tells the shared skeleton about itself."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class CorpusSpec:
13
+ """One collection's names and hooks.
14
+
15
+ `env_var` overrides the corpus directory (the old `NISTLIB_CORPUS`), else `default_dir()` gives it.
16
+ `read_defaults(root)` returns `{publication name: doc_id}` for bare names with no revision, and `alias(key)`
17
+ turns a normalised reference into such a name (NIST: "nist sp 800-53" -> "800-53"). Both are optional.
18
+
19
+ `fetch_command` and `index_command` are what a human runs when the corpus or its search index is missing or
20
+ stale; they become an error's `action:` line. With no `index_command` the fetch command is named, since a fetch
21
+ also builds the index.
22
+ """
23
+
24
+ name: str
25
+ server_name: str
26
+ env_var: str
27
+ fetch_command: str
28
+ default_dir: Callable[[], Path]
29
+ instructions: str
30
+ cite_example: str
31
+ read_defaults: Callable[[Path], Mapping[str, str]] | None = None
32
+ alias: Callable[[str], str] | None = None
33
+ index_command: str = ""
34
+
35
+ def corpus_dir(self) -> Path:
36
+ env = os.environ.get(self.env_var)
37
+ return Path(env) if env else self.default_dir()
@@ -0,0 +1,391 @@
1
+ """The numbered-heading splitter shared by the fetched-PDF collections (nist, usb): pdftotext text in, one
2
+ `Section` per heading out (ROADMAP `cl.segment_numbered`).
3
+
4
+ It was usblib's splitter, the better of the two copies, chosen by measurement (docs/validation.md): against the
5
+ PDF bookmarks it beat nistlib's copy on NIST's own documents (median agreement 0.93 against 0.60) and on USB's.
6
+ A heading is accepted when it is the next child of a parent already seen, or when the document's own contents
7
+ lists it (`_admitted_by_contents`), which stops one missed heading from dropping every later sibling.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections import Counter
14
+ from collections.abc import Iterable
15
+
16
+ from primarysource.pdfcorpus.models import Section
17
+
18
+ # "Appendix A. Title" -> id "A"; "A.4.1. Title" -> id "A.4.1"; "3.2.4. Title" or "3.2.4 Title" -> id "3.2.4".
19
+ # The title may follow the id without a space ("A.5.1.1.1GENERAL ...") when pdftotext loses the gap.
20
+ # Also the colon and dash forms NIST uses ("Appendix A: Title", sp800-38c line 580).
21
+ _APPENDIX = re.compile(r"^\f?\s*(?:Appendix|APPENDIX)\s+(?P<id>[A-Z])\s*[.:—–-]\s*(?P<heading>\S.*)$")
22
+ # A running page header glued in front of a heading by pdftotext -layout (NIST's DOI line or an underscore
23
+ # rule, sp800-218 line 295). Stripped before matching; the line keeps its number.
24
+ _RUNNING_HEADER = re.compile(
25
+ r"^(\f?)\s*(?:This publication is available free of charge from:\s*\S+|_{20,})\s{2,}(?=\S)"
26
+ )
27
+ _LETTERED = re.compile(r"^\f?\s*(?P<id>[A-Z](?:\.\d+)+)\.?\s*(?P<heading>[A-Z].*)$")
28
+ _NUMBERED = re.compile(r"^\f?\s*(?P<id>\d+(?:\.\d+)*)(?P<dot>\.?)\s*(?P<heading>[A-Z].*)$")
29
+ _CHAPTER = re.compile(r"^\f?\s*Chapter\s+(?P<id>\d+)\.?\s+(?P<heading>[A-Z].*)$")
30
+ _CHAPTER_NUMBER_ONLY = re.compile(r"^\f?\s*Chapter\s+(?P<id>\d+)\.?\s*$")
31
+ # "Chapter 1: Introduction". The title must be single-spaced to the end of the line: a running page header
32
+ # pads the title out to a column ("Chapter 1: Introduction Revision 1.1 3/3/2024") and is not a heading.
33
+ # (usblib d15f16d: SuperSpeed electrical compliance, 2 wrong sections to 4 right ones.)
34
+ _CHAPTER_COLON = re.compile(r"^\f?\s*Chapter\s+(?P<id>\d+):\s+(?P<heading>[A-Z]\S*(?: \S+)*)\s*$")
35
+ _LEADER = re.compile(r"\.{4,}|(?:\. ){4,}")
36
+ # A contents row without a dot leader: "1 Introduction 15", a page number after a run of spaces.
37
+ _CONTENTS_ROW = re.compile(r"^\f?\s*(?P<id>\d+)\s+(?P<heading>[A-Z].*?[A-Za-z)])\s{3,}\d+\s*$")
38
+ _MAX_HEADING = 120
39
+ _MIN_RUNNING_REPEATS = 3
40
+ # The foreign headings `_retract_foreign_tail` may drop at once (USB's case was 3: chapters 3, 4 and 4.1).
41
+ _MAX_FOREIGN_TAIL = 3
42
+
43
+
44
+ _MARGIN_NUMBER = re.compile(r"^(\f?\s*)\d{1,4}\s{2,}(?=\d+(?:\.\d+)*\.?\s+[A-Z])")
45
+
46
+
47
+ def _strip_margin(line: str) -> str:
48
+ """Drop the margin line number that some specifications print before a dotted heading ("119 2.2.3 Title")."""
49
+ m = _MARGIN_NUMBER.match(line)
50
+ return line if m is None else m.group(1) + line[m.end() :]
51
+
52
+
53
+ def _heading_at(lines: list[str], index: int) -> tuple[str, str] | None:
54
+ """Heading at ``lines[index]``: ``Chapter N. Title``, ``Chapter N`` with the title next, or a numbered line."""
55
+ line = _strip_margin(lines[index])
56
+ m = _CHAPTER.match(line) or _CHAPTER_COLON.match(line)
57
+ if m is not None:
58
+ return _accept_title(m.group("id"), m.group("heading"))
59
+ m = _CHAPTER_NUMBER_ONLY.match(line)
60
+ if m is not None:
61
+ for following in lines[index + 1 : index + 3]:
62
+ if following.strip():
63
+ return _accept_title(m.group("id"), following.strip())
64
+ return None
65
+ return _match_heading(line)
66
+
67
+
68
+ def _accept_title(sec_id: str, title: str) -> tuple[str, str] | None:
69
+ """A chapter title is accepted when it reads as a title: capitalised, short, and not a table-of-contents row."""
70
+ title = title.strip()
71
+ if _LEADER.search(title) or len(title) > _MAX_HEADING or title.endswith("."):
72
+ return None
73
+ if not title[:1].isupper():
74
+ return None
75
+ return sec_id, title
76
+
77
+
78
+ def _match_heading(line: str) -> tuple[str, str] | None:
79
+ """Return (id, heading) when ``line`` looks like a section heading, else None."""
80
+ for pattern in (_APPENDIX, _LETTERED, _NUMBERED):
81
+ m = pattern.match(line)
82
+ if m is None:
83
+ continue
84
+ heading = m.group("heading").strip()
85
+ # A bare chapter number ("3 CCID Functional ...") is a heading only when it reads as one: a table row
86
+ # has runs of spaces between columns, and a wrapped note has an unclosed bracket.
87
+ bare_chapter = pattern is _NUMBERED and not m.group("dot") and "." not in m.group("id")
88
+ if bare_chapter and (" " in heading or heading.count("(") != heading.count(")")):
89
+ return None
90
+ if _LEADER.search(heading) or len(heading) > _MAX_HEADING or heading.endswith("."):
91
+ return None # table-of-contents row or running prose
92
+ return m.group("id"), heading
93
+ return None
94
+
95
+
96
+ def _ordinal(part: str) -> int:
97
+ return int(part) if part.isdigit() else ord(part) - ord("A") + 1
98
+
99
+
100
+ def _canon(sec_id: str) -> str:
101
+ """``03.01`` and ``3.1`` are one id for counting (sp800-171r3 numbers its requirements ``03.01.01``)."""
102
+ return ".".join(str(int(part)) if part.isdigit() else part for part in sec_id.split("."))
103
+
104
+
105
+ def _parent_key(sec_id: str) -> str:
106
+ """Key of the counter ``sec_id`` belongs to: its parent id, or one of two top-level sequences."""
107
+ parent, _, _ = sec_id.rpartition(".")
108
+ if parent:
109
+ return _canon(parent)
110
+ return "#appendix" if sec_id.isalpha() else "#chapter"
111
+
112
+
113
+ def _is_next(sec_id: str, last_child: dict[str, int]) -> bool:
114
+ """True when ``sec_id`` is the next child of a parent already seen (``1`` and ``A`` open the top levels)."""
115
+ key = _parent_key(sec_id)
116
+ if key not in last_child and not key.startswith("#"):
117
+ return False
118
+ return _ordinal(sec_id.rpartition(".")[2]) == last_child.get(key, 0) + 1
119
+
120
+
121
+ def _running_titles(hits: Iterable[tuple[str, str]]) -> set[str]:
122
+ """Titles that follow a bare number on many lines: page footers and headers such as ``5 Revision 1.0``.
123
+
124
+ A chapter title is not repeated behind three different chapter numbers, but a footer is repeated behind
125
+ every page number, and the next page number would otherwise pass as the next chapter. A title repeated
126
+ behind one number only (``2. IAD Use Model Example`` at the top of every page of chapter 2) is that
127
+ chapter's running header, not a footer, and is left alone (usblib 2ff1feb).
128
+ """
129
+ counts: Counter[str] = Counter()
130
+ numbers: dict[str, set[str]] = {}
131
+ for sec_id, heading in hits:
132
+ if "." not in sec_id and sec_id.isdigit():
133
+ counts[heading] += 1
134
+ numbers.setdefault(heading, set()).add(sec_id)
135
+ return {
136
+ heading for heading, count in counts.items() if count >= _MIN_RUNNING_REPEATS and len(numbers[heading]) >= 2
137
+ }
138
+
139
+
140
+ def _counters(found: list[tuple[str, str, int]]) -> dict[str, int]:
141
+ """Rebuild the per-parent counters from the headings accepted so far."""
142
+ last_child: dict[str, int] = {}
143
+ for sec_id, _, _ in found:
144
+ _advance(last_child, sec_id)
145
+ return last_child
146
+
147
+
148
+ def _advance(last_child: dict[str, int], sec_id: str) -> None:
149
+ """Record ``sec_id`` as the latest child of its parent. Its own number, not a count: a heading admitted by
150
+ the contents after a gap (``_admitted_by_contents``) moves the counter past the gap."""
151
+ key = _parent_key(sec_id)
152
+ last_child[key] = max(last_child.get(key, 0), _ordinal(sec_id.rpartition(".")[2]))
153
+ last_child.setdefault(_canon(sec_id), 0)
154
+
155
+
156
+ def _admitted_by_contents(
157
+ sec_id: str, heading: str, last_child: dict[str, int], toc: set[tuple[str, str]], toc_ids: set[str]
158
+ ) -> bool:
159
+ """A heading the sequence rule rejected, but which the document's own contents lists under the same number.
160
+
161
+ The sequence rule is what keeps list items and cross-references out, and it is also why one missed heading
162
+ used to drop every later sibling and child: the next one is never "next" (docs/validation.md, usb: 1,485 of
163
+ 1,749 misses). The contents is an independent list of the real headings, so a heading it names may skip a
164
+ gap. Forward only, so a contents row met again later cannot rewind the counters, and its parent must be
165
+ known (seen, or itself in the contents).
166
+ """
167
+ if sec_id not in toc_ids:
168
+ return False
169
+ title = _normal(heading)
170
+ if not any(
171
+ toc_id == sec_id and (toc_title == title or toc_title.startswith(title) or title.startswith(toc_title))
172
+ for toc_id, toc_title in toc
173
+ if len(toc_title) >= 3
174
+ ):
175
+ return False
176
+ key = _parent_key(sec_id)
177
+ if not key.startswith("#") and key not in last_child and key not in {_canon(t) for t in toc_ids}:
178
+ return False
179
+ return _ordinal(sec_id.rpartition(".")[2]) > last_child.get(key, 0)
180
+
181
+
182
+ def _retract_premature_chapters(found: list[tuple[str, str, int]], sec_id: str) -> bool:
183
+ """Drop trailing chapters that ``sec_id`` proves were false: a list item or footnote read as ``8 Title``.
184
+
185
+ When 7.3 turns up after a chapter 8, the 8 cannot have been a heading, or 7.3 would sit under the
186
+ wrong chapter. Only bare chapter numbers are retracted, and only the unbroken run just before ``sec_id``.
187
+ """
188
+ top = _ordinal(sec_id.partition(".")[0]) if sec_id[0].isdigit() else None
189
+ if top is None:
190
+ return False
191
+ dropped = False
192
+ while found and "." not in found[-1][0] and found[-1][0].isdigit() and int(found[-1][0]) > top:
193
+ found.pop()
194
+ dropped = True
195
+ return dropped
196
+
197
+
198
+ def _number_after_appendix(sec_id: str, last_child: dict[str, int], toc_ids: set[str]) -> bool:
199
+ """Appendices follow the chapters, so a bare number after one is a list item (usblib d15f16d: USB PD's
200
+ revision-history item "11. VCONN Ownership" was read as chapter 11) unless the contents lists it as a
201
+ chapter. Without that exception a stray early "Appendix A" line (a cross-reference in the front matter)
202
+ blocked every chapter after it: NIST sp800-204d fell from 0.97 to 0.05 against its bookmarks."""
203
+ return sec_id.isdigit() and "#appendix" in last_child and _canon(sec_id) not in {_canon(t) for t in toc_ids}
204
+
205
+
206
+ def _retract_foreign_tail(found: list[tuple[str, str, int]], sec_id: str) -> bool:
207
+ """Drop the headings accepted after ``X.(n-1)``'s subtree when ``X.n`` shows they were not inside ``X``.
208
+
209
+ A compliance test prints another specification's chapter headers inside its own assertion table, so a
210
+ ``3``, ``4`` and ``4.1`` can be accepted between ``2.3`` and ``2.4``. When ``2.4`` arrives as the next child
211
+ of ``2``, those headings cannot belong to ``2``'s outline. Nothing is dropped when any of them is a
212
+ descendant of ``X``, so an ordinary subsection is never retracted by its sibling (usblib d15f16d). Nor when
213
+ the tail is longer than ``_MAX_FOREIGN_TAIL``: then ``X.n`` is more likely a cross-reference deep in a later
214
+ chapter than the end of a short foreign table, and retracting would drop real chapters (measured on NIST:
215
+ unlimited, the median agreement fell from 0.929 to 0.880).
216
+ Ids are compared in their counting form, so ``03.01.02`` finds ``03.01.01``.
217
+ """
218
+ parent, _, last = sec_id.rpartition(".")
219
+ if not parent or not last.isdigit() or int(last) < 2:
220
+ return False
221
+ parent = _canon(parent)
222
+ previous = f"{parent}.{int(last) - 1}"
223
+ position = next((i for i in range(len(found) - 1, -1, -1) if _canon(found[i][0]) == previous), None)
224
+ if position is None:
225
+ return False
226
+ start = position + 1
227
+ while start < len(found) and _canon(found[start][0]).startswith(previous + "."):
228
+ start += 1
229
+ tail = found[start:]
230
+ if not tail or len(tail) > _MAX_FOREIGN_TAIL or any(_canon(entry[0]).startswith(parent + ".") for entry in tail):
231
+ return False
232
+ del found[start:]
233
+ return True
234
+
235
+
236
+ def _retract_unexplained_chapters(found: list[tuple[str, str, int]], chapter_id: str, explicit: set[int]) -> bool:
237
+ """Drop trailing bare numbers at or above ``chapter_id`` that were not written as "Chapter N" lines.
238
+
239
+ An explicit "Chapter 4. Title" line proves that an earlier bare "4. A Source May ..." list item, which
240
+ arrived first, was not a heading. Only the unbroken run of such bare numbers at the end is dropped.
241
+ """
242
+ number = int(chapter_id)
243
+ dropped = False
244
+ while (
245
+ found
246
+ and "." not in found[-1][0]
247
+ and found[-1][0].isdigit()
248
+ and int(found[-1][0]) >= number
249
+ and found[-1][2] not in explicit
250
+ ):
251
+ found.pop()
252
+ dropped = True
253
+ return dropped
254
+
255
+
256
+ def _contents_entries(lines: list[str]) -> set[tuple[str, str]]:
257
+ """(id, normalised title) of every numbered row of a table of contents.
258
+
259
+ A row either ends in a dot leader, or is a number and title followed by a page number after a run of
260
+ spaces, as in the HID Usage Tables contents ("1 Introduction 15").
261
+ """
262
+ entries: set[tuple[str, str]] = set()
263
+ for line in lines:
264
+ leader = _LEADER.search(line)
265
+ if leader is not None:
266
+ m = _NUMBERED.match(line[: leader.start()].rstrip() or "x")
267
+ if m is not None:
268
+ entries.add((m.group("id"), _normal(m.group("heading"))))
269
+ continue
270
+ m = _CONTENTS_ROW.match(line)
271
+ if m is not None:
272
+ entries.add((m.group("id"), _normal(m.group("heading"))))
273
+ return entries
274
+
275
+
276
+ def _normal(title: str) -> str:
277
+ return " ".join(title.split()).casefold()
278
+
279
+
280
+ def _drop_childless_chapters(
281
+ found: list[tuple[str, str, int]], contents: set[tuple[str, str]]
282
+ ) -> list[tuple[str, str, int]]:
283
+ """Drop runs of two or more consecutive bare chapters that own no ``N.x`` subsection.
284
+
285
+ Numbered steps in a test procedure ("4 Verify that the link is up", "5 Reset the port") read exactly like
286
+ chapters but come in runs with no subsection between them. A lone chapter without subsections, such as
287
+ "2 Normative References" between two structured chapters, is kept, and so is any chapter that the document's
288
+ own table of contents lists with the same title. A document in which no chapter has a subsection is left
289
+ alone, since nothing then separates its chapters from its steps.
290
+ """
291
+ with_children = {sec_id.partition(".")[0] for sec_id, _, _ in found if "." in sec_id}
292
+ if not with_children:
293
+ return found
294
+
295
+ def bare(entry: tuple[str, str, int]) -> bool:
296
+ return entry[0].isdigit() and entry[0] not in with_children and (entry[0], _normal(entry[1])) not in contents
297
+
298
+ kept: list[tuple[str, str, int]] = []
299
+ run: list[tuple[str, str, int]] = []
300
+ for entry in [*found, None]:
301
+ if entry is not None and bare(entry):
302
+ run.append(entry)
303
+ continue
304
+ if len(run) < 2:
305
+ kept.extend(run)
306
+ run = []
307
+ if entry is not None:
308
+ kept.append(entry)
309
+ return kept
310
+
311
+
312
+ def _reads_as_prose(title: str) -> bool:
313
+ """True when a heading carries running text or a table row: a sentence break, a mid-line colon, or columns.
314
+
315
+ A label that ends in a colon ("Host Key(s):") is a heading, not running text, so it does not count here.
316
+ """
317
+ return ": " in title or ". " in title or " " in title
318
+
319
+
320
+ def _shallow_chapters(toc_ids: set[str]) -> set[str]:
321
+ """Chapters the contents list without any subsection, in a contents that does list subsections elsewhere.
322
+
323
+ When some chapters have their subsections listed and a chapter does not, its subsections are either absent
324
+ or were misread as headings, and a heading under such a chapter must then also read as running text.
325
+ """
326
+ deep = {sec_id.partition(".")[0] for sec_id in toc_ids if "." in sec_id}
327
+ chapters = {sec_id for sec_id in toc_ids if "." not in sec_id}
328
+ return chapters - deep if deep else set()
329
+
330
+
331
+ def split_sections(text: str) -> tuple[Section, ...]:
332
+ """Find numbered headings in ``text`` and return one Section per heading.
333
+
334
+ Lines are numbered from 1 and split on newline only, so form feeds do not shift the numbering. Sections
335
+ do not nest: each runs from its heading to the line before the next heading (the last runs to the end).
336
+ A heading is accepted only when it is the next child of a parent already seen (3.2.4 after 3.2.3 inside
337
+ 3.2; 2 after 1; B after A). That keeps numbered list items and cross-references that look like headings
338
+ from being read as sections, and it also means one missed heading drops its later siblings.
339
+ """
340
+ lines = [_RUNNING_HEADER.sub(r"\1", line) for line in text.split("\n")]
341
+ toc = _contents_entries(lines)
342
+ toc_ids = {sec_id for sec_id, _ in toc}
343
+ shallow = _shallow_chapters(toc_ids)
344
+ if lines and lines[-1] == "":
345
+ lines.pop()
346
+ hits = [(number, _heading_at(lines, number - 1)) for number in range(1, len(lines) + 1)]
347
+ # A "Chapter N" header repeats on every page of its chapter; it is never a footer, so it is exempt
348
+ # from the running-title filter and does not count towards it.
349
+ chapter_lines = {
350
+ number
351
+ for number, line in enumerate(lines, start=1)
352
+ if _CHAPTER.match(line) or _CHAPTER_COLON.match(line) or _CHAPTER_NUMBER_ONLY.match(line)
353
+ }
354
+ running = _running_titles(hit for number, hit in hits if hit is not None and number not in chapter_lines)
355
+ found: list[tuple[str, str, int]] = []
356
+ last_child: dict[str, int] = {}
357
+ for number, hit in hits:
358
+ if hit is None:
359
+ continue
360
+ sec_id, heading = hit
361
+ if "." not in sec_id and heading in running and number not in chapter_lines:
362
+ continue
363
+ if (
364
+ "." not in sec_id
365
+ and number in chapter_lines
366
+ and _retract_unexplained_chapters(found, sec_id, chapter_lines)
367
+ ):
368
+ last_child = _counters(found)
369
+ if (
370
+ "." in sec_id
371
+ and sec_id.partition(".")[0] in shallow
372
+ and sec_id not in toc_ids
373
+ and _reads_as_prose(heading)
374
+ ):
375
+ continue # a cross-reference or table cell under a chapter the contents shows without subsections
376
+ if _number_after_appendix(sec_id, last_child, toc_ids):
377
+ continue
378
+ if not _is_next(sec_id, last_child) and not _admitted_by_contents(sec_id, heading, last_child, toc, toc_ids):
379
+ continue
380
+ if "." in sec_id and _retract_foreign_tail(found, sec_id):
381
+ last_child = _counters(found)
382
+ if "." in sec_id and _retract_premature_chapters(found, sec_id):
383
+ last_child = _counters(found)
384
+ _advance(last_child, sec_id)
385
+ found.append((sec_id, heading, number))
386
+ found = _drop_childless_chapters(found, toc)
387
+ sections: list[Section] = []
388
+ for index, (sec_id, heading, start) in enumerate(found):
389
+ end = found[index + 1][2] - 1 if index + 1 < len(found) else len(lines)
390
+ sections.append(Section(id=sec_id, heading=heading, start_line=start, end_line=end))
391
+ return tuple(sections)
@@ -0,0 +1,244 @@
1
+ """The five tools as plain functions returning `render()` text. ``ToolError`` marks a caller mistake.
2
+
3
+ Every error is a `LibError`: the message says what is wrong, `hint` names the tool call that helps next (with the
4
+ collection's prefix, `nist_outline('sp800-73pt2-5')`), and `action` the fetch or index command when a human must
5
+ act. A search that matches nothing is text saying so, not an error.
6
+
7
+ Cursors (PLAN.md §5.11): a read stops at its token budget and its `next:` is a ref, `<doc_id>:<first>-<last>`, that
8
+ `read` takes back as its `doc`; search and outline are lists whose `next:` is `start=<n>`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+
16
+ from primarysource import budget, index
17
+ from primarysource.errors import LibError
18
+ from primarysource.pdfcorpus import fts
19
+ from primarysource.pdfcorpus.corpus import Corpus
20
+ from primarysource.pdfcorpus.models import Document, Section
21
+ from primarysource.refs import RefGrammar
22
+ from primarysource.render import next_start, render
23
+
24
+ # Document ids are file-safe slugs (`sp800-73pt2-5`, `sp800-53r5-oscal`), matched without regard to case.
25
+ # Section ids are dotted numbers, appendix letters and control ids (`3.2.4`, `A.1`, `AC-2(1)`).
26
+ GRAMMAR = RefGrammar(r"[A-Za-z0-9][A-Za-z0-9._-]*", r"[0-9A-Za-z][0-9A-Za-z.()_-]*")
27
+
28
+ REF_FORMS = "use <doc_id>§<section> or <doc_id>:<first>-<last>"
29
+ READ_TOKENS = 2000
30
+ OUTLINE_ROWS = 200
31
+
32
+
33
+ class ToolError(LibError):
34
+ """A bad argument or unknown reference; `hint` says what to call instead."""
35
+
36
+
37
+ def _norm(s: str) -> str:
38
+ return " ".join(s.lower().split())
39
+
40
+
41
+ def resolve_doc(corpus: Corpus, doc: str) -> Document:
42
+ """Find a document by ``doc_id`` or by ``number`` (case/space-insensitive).
43
+
44
+ Failing that, a bare name with no revision resolves through the corpus's defaults (NIST: "NIST SP 800-53"
45
+ -> the current final revision), when the collection supplies them. An explicit id or number always wins.
46
+ """
47
+ key = _norm(doc)
48
+ for d in corpus.documents:
49
+ if _norm(d.doc_id) == key or _norm(d.number) == key:
50
+ return d
51
+ if corpus.defaults:
52
+ wanted = corpus.defaults.get(corpus.alias(key) if corpus.alias else key)
53
+ if wanted is not None:
54
+ for d in corpus.documents:
55
+ if d.doc_id == wanted:
56
+ return d
57
+ known = ", ".join(d.doc_id for d in corpus.documents) or "none"
58
+ raise ToolError(f"unknown document {doc!r} (known doc_ids: {known})", f"{corpus.tool('search')}('<words>')")
59
+
60
+
61
+ def resolve_section(corpus: Corpus, document: Document, section: str) -> Section:
62
+ sid = section.strip().lstrip("§").strip()
63
+ for s in document.sections:
64
+ if s.id == sid:
65
+ return s
66
+ raise ToolError(f"{document.doc_id} has no section {sid!r}", f"{corpus.tool('outline')}({document.doc_id!r})")
67
+
68
+
69
+ def parse_lines(spec: str) -> tuple[int, int]:
70
+ m = re.fullmatch(r"\s*(\d+)\s*(?:[-:]\s*(\d+))?\s*", spec)
71
+ if not m:
72
+ raise ToolError(f"cannot parse lines={spec!r}", "give 'START-END', e.g. lines='120-160'")
73
+ start = int(m.group(1))
74
+ return start, int(m.group(2) or start)
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class Target:
79
+ """What a read or cite names: a document and a section, a line range, or neither."""
80
+
81
+ document: Document
82
+ section: Section | None = None
83
+ lines: tuple[int, int] | None = None
84
+
85
+
86
+ def resolve(corpus: Corpus, doc: str, section: str | None = None, lines: str | None = None) -> Target:
87
+ """`doc` is a doc_id, a number, or a ref: `<doc_id>§<section>` or `<doc_id>:<first>-<last>` (a read's `next:`).
88
+
89
+ A ref's section or range and an explicit `section`/`lines` argument may not both be given.
90
+ """
91
+ if section is not None and lines is not None:
92
+ raise ToolError("give one of section or lines, not both", "")
93
+ ref = None
94
+ if "§" in doc or "::" in doc or ":" in doc:
95
+ ref = GRAMMAR.parse(doc)
96
+ if ref.para is not None or ref.anchor is not None:
97
+ raise ToolError(f"{doc!r}: this collection has no ¶ or # refs", REF_FORMS)
98
+ if ref.doc is None:
99
+ raise ToolError(f"{doc!r} names no document", REF_FORMS)
100
+ if section is not None or lines is not None:
101
+ raise ToolError(f"{doc!r} already names a section or lines; drop the section/lines argument", "")
102
+ document = resolve_doc(corpus, ref.doc if ref is not None and ref.doc else doc)
103
+ if ref is not None and ref.section is not None:
104
+ section = ref.section
105
+ if section is not None:
106
+ return Target(document, section=resolve_section(corpus, document, section))
107
+ span = ref.lines if ref is not None else (parse_lines(lines) if lines is not None else None)
108
+ return Target(document, lines=span)
109
+
110
+
111
+ def _numbered(lines: list[str], start: int, end: int, width: int) -> list[str]:
112
+ return [f"{n:>{width}}: {lines[n - 1]}" for n in range(start, end + 1)]
113
+
114
+
115
+ def _section_lines(document: Document) -> dict[str, Section]:
116
+ return {s.id: s for s in document.sections}
117
+
118
+
119
+ def search(corpus: Corpus, query: str, doc: str | None = None, limit: int = 8, start: int = 0) -> str:
120
+ """Sections best matching `query` from the FTS5 index, `limit` from the 0-based `start`."""
121
+ if not index.query_terms(query):
122
+ raise ToolError("the query has no words to search for", f"{corpus.tool('search')}('<words>')")
123
+ if limit < 1:
124
+ raise ToolError("limit must be at least 1", "")
125
+ if start < 0:
126
+ raise ToolError("start is a 0-based offset", "pass the start=<n> of a next: line")
127
+ only = resolve_doc(corpus, doc).doc_id if doc else None
128
+ conn = fts.open_index(corpus)
129
+ try:
130
+ result = fts.search(conn, query, doc_id=only, limit=limit, start=start)
131
+ finally:
132
+ conn.close()
133
+ if not result.hits:
134
+ if start:
135
+ return render(f"No more passages for {query!r} after {start}.", {})
136
+ return render(f"No passages match {query!r}.", {})
137
+ documents = {d.doc_id: d for d in corpus.documents}
138
+ out = [f"Passages {start + 1}-{start + len(result.hits)} for {query!r}, best first:"]
139
+ for hit in result.hits:
140
+ document = documents.get(hit.doc_id)
141
+ section = _section_lines(document).get(hit.section_id) if document else None
142
+ where = f"lines {section.start_line}-{section.end_line}, " if section else ""
143
+ snippet = " ".join(hit.snippet.split())
144
+ out.append(f"\n{hit.doc_id}§{hit.section_id} {hit.title} ({where}score {hit.score:.3g})\n {snippet}")
145
+ footer = {
146
+ "next": next_start(result.next_start),
147
+ "relaxed": "no section holds every query word; these hold some" if result.relaxed else "",
148
+ }
149
+ return render("\n".join(out), footer)
150
+
151
+
152
+ def read(
153
+ corpus: Corpus,
154
+ doc: str,
155
+ section: str | None = None,
156
+ lines: str | None = None,
157
+ max_tokens: int = READ_TOKENS,
158
+ ) -> str:
159
+ """Numbered lines of a section or a line range, cut at whole lines within `max_tokens`."""
160
+ if max_tokens < 1:
161
+ raise ToolError("max_tokens must be at least 1", "")
162
+ target = resolve(corpus, doc, section, lines)
163
+ d = target.document
164
+ if target.section is not None:
165
+ s = target.section
166
+ start, end, label = s.start_line, s.end_line, f"{d.doc_id}§{s.id} {s.heading}"
167
+ elif target.lines is not None:
168
+ start, end = target.lines
169
+ label = d.doc_id
170
+ else:
171
+ raise ToolError(
172
+ f"say what to read in {d.doc_id}: a section or a line range",
173
+ f"{corpus.tool('outline')}({d.doc_id!r}) lists sections; then {corpus.tool('read')}('{d.doc_id}§<id>')",
174
+ )
175
+ if start < 1 or end < start:
176
+ raise ToolError(f"invalid line range {start}-{end}", "lines are 1-based and START <= END")
177
+ text = corpus.text_lines(d)
178
+ if end > len(text):
179
+ raise ToolError(f"line range {start}-{end} is past the end of {d.doc_id} ({len(text)} lines)", "")
180
+ width = len(str(end))
181
+ taken: list[str] = []
182
+ used = 0
183
+ for row in _numbered(text, start, end, width):
184
+ cost = budget.tokens(row + "\n")
185
+ if taken and used + cost > max_tokens:
186
+ break
187
+ taken.append(row)
188
+ used += cost
189
+ last = start + len(taken) - 1
190
+ shown = f"lines {start}-{last}" + ("" if last == end else f" of {start}-{end}")
191
+ cite = f"{d.number} §{target.section.id}" if target.section is not None else f"{d.number}, lines {start}-{last}"
192
+ footer = {
193
+ "cite": cite,
194
+ "next": f"{d.doc_id}:{last + 1}-{end}" if last < end else "",
195
+ "warning": f"status is {d.status!r}, not 'final'" if d.status.lower() != "final" else "",
196
+ }
197
+ return render(f"{label} ({shown})\n" + "\n".join(taken), footer)
198
+
199
+
200
+ def outline(corpus: Corpus, doc: str, start: int = 0, limit: int = OUTLINE_ROWS) -> str:
201
+ d = resolve_doc(corpus, doc)
202
+ if limit < 1:
203
+ raise ToolError("limit must be at least 1", "")
204
+ if start < 0:
205
+ raise ToolError("start is a 0-based offset", "pass the start=<n> of a next: line")
206
+ if not d.sections:
207
+ return render(f"{d.doc_id}: the index lists no sections for this document.", {})
208
+ rows = d.sections[start : start + limit]
209
+ out = [f"{d.number}, {d.title}"]
210
+ for s in rows:
211
+ indent = " " * s.id.count(".")
212
+ out.append(f"{indent}{s.id} {s.heading} (lines {s.start_line}-{s.end_line})")
213
+ more = start + limit < len(d.sections)
214
+ return render("\n".join(out), {"next": next_start(start + limit if more else None)})
215
+
216
+
217
+ def info(corpus: Corpus, doc: str) -> str:
218
+ d = resolve_doc(corpus, doc)
219
+ out = [
220
+ f"doc_id: {d.doc_id}",
221
+ f"number: {d.number}",
222
+ f"title: {d.title}",
223
+ f"date: {d.date}",
224
+ f"status: {d.status}",
225
+ f"url: {d.url}",
226
+ f"sha256: {d.sha256}",
227
+ f"sections: {len(d.sections)}",
228
+ ]
229
+ warning = ""
230
+ if d.status.lower() != "final":
231
+ warning = f"status is {d.status!r}, not 'final'. Check for a current version before relying on it."
232
+ return render("\n".join(out), {"warning": warning})
233
+
234
+
235
+ def cite(corpus: Corpus, doc: str, section: str | None = None) -> str:
236
+ target = resolve(corpus, doc, section)
237
+ d = target.document
238
+ if target.section is not None:
239
+ s = target.section
240
+ return render(f"{d.number} §{s.id}\n{d.url}\nHeading: {s.heading}", {})
241
+ if target.lines is not None:
242
+ first, last = target.lines
243
+ return render(f"{d.number}, lines {first}-{last}\n{d.url}", {})
244
+ return render(f"{d.number}\n{d.url}", {})