rdstudio 0.2.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 (251) hide show
  1. rdstudio/__init__.py +3 -0
  2. rdstudio/_node/rdstudio.mjs +59351 -0
  3. rdstudio/brief.py +46 -0
  4. rdstudio/build.py +218 -0
  5. rdstudio/classify.py +131 -0
  6. rdstudio/cli.py +369 -0
  7. rdstudio/config.py +115 -0
  8. rdstudio/gitlog.py +92 -0
  9. rdstudio/launcher.py +63 -0
  10. rdstudio/learner.py +187 -0
  11. rdstudio/mcp_server.py +313 -0
  12. rdstudio/okf.py +775 -0
  13. rdstudio/procedures.py +233 -0
  14. rdstudio/references.py +228 -0
  15. rdstudio/reports.py +75 -0
  16. rdstudio/scaffold.py +227 -0
  17. rdstudio/scopes.py +162 -0
  18. rdstudio/search.py +149 -0
  19. rdstudio/serve.py +214 -0
  20. rdstudio/store.py +161 -0
  21. rdstudio/templates/agents/critic.md +20 -0
  22. rdstudio/templates/agents/librarian.md +21 -0
  23. rdstudio/templates/agents/searcher.md +21 -0
  24. rdstudio/templates/agents_section.md +31 -0
  25. rdstudio/templates/claude_section.md +3 -0
  26. rdstudio/templates/knowledge/overview.md +20 -0
  27. rdstudio/templates/knowledge/tasks/bootstrap-knowledge-base.md +45 -0
  28. rdstudio/templates/skills/decision/SKILL.md +47 -0
  29. rdstudio/templates/skills/handoff/SKILL.md +18 -0
  30. rdstudio/templates/skills/ingest-ref/SKILL.md +32 -0
  31. rdstudio/templates/skills/lint-okf/SKILL.md +15 -0
  32. rdstudio/templates/skills/promote/SKILL.md +25 -0
  33. rdstudio/templates/skills/question/SKILL.md +33 -0
  34. rdstudio/templates/skills/record-okf/SKILL.md +61 -0
  35. rdstudio/templates/skills/report/SKILL.md +46 -0
  36. rdstudio/templates/skills/report/template.html +74 -0
  37. rdstudio/templates/skills/search-okf/SKILL.md +36 -0
  38. rdstudio/templates/skills/task/SKILL.md +51 -0
  39. rdstudio/web/app.js +140 -0
  40. rdstudio/web/fonts.css +48 -0
  41. rdstudio/web/icon-192.png +0 -0
  42. rdstudio/web/icon-512.png +0 -0
  43. rdstudio/web/index.html +109 -0
  44. rdstudio/web/js/data.js +125 -0
  45. rdstudio/web/js/diagrams.js +116 -0
  46. rdstudio/web/js/graph.js +396 -0
  47. rdstudio/web/js/knowledge.js +212 -0
  48. rdstudio/web/js/layout-worker.js +15 -0
  49. rdstudio/web/js/layout.js +226 -0
  50. rdstudio/web/js/learn.js +83 -0
  51. rdstudio/web/js/map.js +1293 -0
  52. rdstudio/web/js/markdown.js +173 -0
  53. rdstudio/web/js/pages.js +219 -0
  54. rdstudio/web/js/procedures.js +262 -0
  55. rdstudio/web/js/settings.js +95 -0
  56. rdstudio/web/js/util.js +99 -0
  57. rdstudio/web/report.css +35 -0
  58. rdstudio/web/report.js +104 -0
  59. rdstudio/web/style.css +566 -0
  60. rdstudio/web/sw.js +166 -0
  61. rdstudio/web/themes/cyber.css +139 -0
  62. rdstudio/web/themes/map.css +133 -0
  63. rdstudio/web/themes/notebook.css +134 -0
  64. rdstudio/web/themes/space.css +135 -0
  65. rdstudio/web/themes/studio.css +110 -0
  66. rdstudio/web/vendor/d3.min.js +2 -0
  67. rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-ext-wght-italic.woff2 +0 -0
  68. rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-ext-wght-normal.woff2 +0 -0
  69. rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-wght-italic.woff2 +0 -0
  70. rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-wght-normal.woff2 +0 -0
  71. rdstudio/web/vendor/fonts/caveat-latin-wght-normal.woff2 +0 -0
  72. rdstudio/web/vendor/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  73. rdstudio/web/vendor/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  74. rdstudio/web/vendor/fonts/ibm-plex-sans-latin-wght-italic.woff2 +0 -0
  75. rdstudio/web/vendor/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  76. rdstudio/web/vendor/fonts/inter-latin-opsz-italic.woff2 +0 -0
  77. rdstudio/web/vendor/fonts/inter-latin-opsz-normal.woff2 +0 -0
  78. rdstudio/web/vendor/fonts/jetbrains-mono-latin-wght-italic.woff2 +0 -0
  79. rdstudio/web/vendor/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  80. rdstudio/web/vendor/fonts/literata-latin-ext-opsz-italic.woff2 +0 -0
  81. rdstudio/web/vendor/fonts/literata-latin-ext-opsz-normal.woff2 +0 -0
  82. rdstudio/web/vendor/fonts/literata-latin-opsz-italic.woff2 +0 -0
  83. rdstudio/web/vendor/fonts/literata-latin-opsz-normal.woff2 +0 -0
  84. rdstudio/web/vendor/fonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  85. rdstudio/web/vendor/fonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  86. rdstudio/web/vendor/fonts/source-serif-4-latin-opsz-italic.woff2 +0 -0
  87. rdstudio/web/vendor/fonts/source-serif-4-latin-opsz-normal.woff2 +0 -0
  88. rdstudio/web/vendor/hljs/github-dark.min.css +10 -0
  89. rdstudio/web/vendor/hljs/github.min.css +10 -0
  90. rdstudio/web/vendor/hljs/highlight.min.js +1262 -0
  91. rdstudio/web/vendor/hljs/julia.min.js +18 -0
  92. rdstudio/web/vendor/hljs/latex.min.js +33 -0
  93. rdstudio/web/vendor/hljs/matlab.min.js +15 -0
  94. rdstudio/web/vendor/katex/auto-render.min.js +1 -0
  95. rdstudio/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  96. rdstudio/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  97. rdstudio/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  98. rdstudio/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  99. rdstudio/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  100. rdstudio/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  101. rdstudio/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  102. rdstudio/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  103. rdstudio/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  104. rdstudio/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  105. rdstudio/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  106. rdstudio/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  107. rdstudio/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  108. rdstudio/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  109. rdstudio/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  110. rdstudio/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  111. rdstudio/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  112. rdstudio/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  113. rdstudio/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  114. rdstudio/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  115. rdstudio/web/vendor/katex/katex.min.css +1 -0
  116. rdstudio/web/vendor/katex/katex.min.js +1 -0
  117. rdstudio/web/vendor/licenses/atkinson-hyperlegible-next-OFL.txt +93 -0
  118. rdstudio/web/vendor/licenses/caveat-OFL.txt +93 -0
  119. rdstudio/web/vendor/licenses/d3-7.9.0.txt +13 -0
  120. rdstudio/web/vendor/licenses/highlightjs-cdn-assets-11.12.0.txt +29 -0
  121. rdstudio/web/vendor/licenses/ibm-plex-mono-OFL.txt +93 -0
  122. rdstudio/web/vendor/licenses/ibm-plex-sans-OFL.txt +93 -0
  123. rdstudio/web/vendor/licenses/inter-OFL.txt +93 -0
  124. rdstudio/web/vendor/licenses/jetbrains-mono-OFL.txt +93 -0
  125. rdstudio/web/vendor/licenses/katex-0.18.7.txt +21 -0
  126. rdstudio/web/vendor/licenses/literata-OFL.txt +93 -0
  127. rdstudio/web/vendor/licenses/markdown-it-15.0.2.txt +22 -0
  128. rdstudio/web/vendor/licenses/markdown-it-footnote-4.0.0.txt +22 -0
  129. rdstudio/web/vendor/licenses/markdown-it-texmath-1.0.0.txt +21 -0
  130. rdstudio/web/vendor/licenses/mermaid-12.0.0.txt +21 -0
  131. rdstudio/web/vendor/licenses/source-sans-3-OFL.txt +93 -0
  132. rdstudio/web/vendor/licenses/source-serif-4-OFL.txt +93 -0
  133. rdstudio/web/vendor/licenses/vega-6.4.0.txt +27 -0
  134. rdstudio/web/vendor/licenses/vega-embed-7.2.0.txt +27 -0
  135. rdstudio/web/vendor/licenses/vega-lite-6.4.3.txt +27 -0
  136. rdstudio/web/vendor/markdown-it-footnote.min.js +2 -0
  137. rdstudio/web/vendor/markdown-it.min.js +14 -0
  138. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/abnfDiagram-DGLNOSUI.mjs +1 -0
  139. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/architecture-7GRP2DOG-HF4J73XS.mjs +1 -0
  140. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/architectureDiagram-IU6H5EVY.mjs +36 -0
  141. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/blockDiagram-FSX6DT2V.mjs +140 -0
  142. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/c4Diagram-YNRVYILN.mjs +38 -0
  143. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-22SYA5HA.mjs +1 -0
  144. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-2FTYKNOQ.mjs +1 -0
  145. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-36VM3LGA.mjs +1 -0
  146. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-3SEI7E7V.mjs +1 -0
  147. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-3T2MNPEU.mjs +1 -0
  148. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-44AU3KAP.mjs +174 -0
  149. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-4MZOTL6B.mjs +1 -0
  150. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-52IMQVNL.mjs +1 -0
  151. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-5NYYYZGL.mjs +1 -0
  152. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-BOZ75642.mjs +15 -0
  153. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-CVBX6EWK.mjs +1 -0
  154. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-D36F7FYG.mjs +14 -0
  155. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-DEGLBX6O.mjs +213 -0
  156. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-F6PIGZHG.mjs +1 -0
  157. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-FE53TMRB.mjs +133 -0
  158. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-FNR2RGIF.mjs +1 -0
  159. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-GDOATIN3.mjs +32 -0
  160. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-H7BX327W.mjs +2 -0
  161. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-HI3EPXIC.mjs +2 -0
  162. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-JCBDXJ5W.mjs +1 -0
  163. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-JGXDHUYS.mjs +1 -0
  164. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-L2B47AAZ.mjs +1 -0
  165. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-LIEGGFIX.mjs +1 -0
  166. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-LLWYPJEZ.mjs +1 -0
  167. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MFYRHTYG.mjs +1 -0
  168. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MIE3LDYP.mjs +1 -0
  169. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MLQVGYRW.mjs +1 -0
  170. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MNT3HHAC.mjs +1 -0
  171. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-NRRTYJAG.mjs +1 -0
  172. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-OCMJQKYE.mjs +10 -0
  173. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-PKJCTQEK.mjs +339 -0
  174. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-QADHU5CF.mjs +2 -0
  175. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-QVIEFLGF.mjs +1 -0
  176. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-QX7LGXZU.mjs +1 -0
  177. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-R2KH53MI.mjs +1 -0
  178. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-RXM7Z7NK.mjs +2 -0
  179. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-S4TE5DX6.mjs +1 -0
  180. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-SDIBIOXT.mjs +1 -0
  181. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-TDFULVG6.mjs +1 -0
  182. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-TECWHJ6D.mjs +62 -0
  183. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-TINYC7RT.mjs +70 -0
  184. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-V3KJR4XU.mjs +1 -0
  185. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-VTMXW4ES.mjs +1 -0
  186. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-Y5NT6LW5.mjs +88 -0
  187. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-Y5O5OPKC.mjs +3 -0
  188. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-YZG4BXY6.mjs +1 -0
  189. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZZ52EIBP.mjs +1 -0
  190. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-v2-VV4INRHB.mjs +217 -0
  191. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/cose-bilkent-MSIMRU2B.mjs +1 -0
  192. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/cynefin-OW5HDTMX-KMPDV6GS.mjs +1 -0
  193. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/cynefinDiagram-KTGLXFXK.mjs +62 -0
  194. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/dagre-QJJCSHY4.mjs +4 -0
  195. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-2ZIM67RP.mjs +3 -0
  196. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-FYHH5O6R.mjs +24 -0
  197. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-LYLDACMS.mjs +200 -0
  198. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-TYT3HRBC.mjs +24 -0
  199. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-XVIN6YVD.mjs +30 -0
  200. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-ZO2NLLYC.mjs +41 -0
  201. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/ebnfDiagram-TU3FGH35.mjs +1 -0
  202. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/elk-GHAXNSLK.mjs +27 -0
  203. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/erDiagram-XID3VV23.mjs +99 -0
  204. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/eventmodeling-NTZA5JFV-P5QJSWSK.mjs +1 -0
  205. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/flowDiagram-WPHQAKTU.mjs +1 -0
  206. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/ganttDiagram-QACGSKG6.mjs +292 -0
  207. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/gitGraph-4MIJSDKK-V5NEQQYP.mjs +1 -0
  208. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/gitGraphDiagram-EB76O4HC.mjs +106 -0
  209. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/info-A6RAGUB7-IGBHH2L7.mjs +1 -0
  210. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/infoDiagram-343ZKIDN.mjs +2 -0
  211. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/ishikawaDiagram-UEEU5D75.mjs +70 -0
  212. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/journeyDiagram-4QTZOVLF.mjs +139 -0
  213. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/kanban-definition-VEJL4RG2.mjs +89 -0
  214. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/katex-QVRI4OIV.mjs +257 -0
  215. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/mindmap-definition-C5K5ZT7T.mjs +96 -0
  216. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/packet-AYTQ26CC-PJTKDECJ.mjs +1 -0
  217. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/pegDiagram-FHIILVJB.mjs +1 -0
  218. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/pie-WAS4IAKB-U3JFQXZN.mjs +1 -0
  219. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/pieDiagram-S76BU6V7.mjs +39 -0
  220. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/quadrantDiagram-HZBLWUZE.mjs +7 -0
  221. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/radar-RG4KPBEZ-7T2UW32D.mjs +1 -0
  222. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-74A4TZTK-DZY7ARDL.mjs +1 -0
  223. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-abnf-HS5TGJTU-LWS535ZK.mjs +1 -0
  224. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-ebnf-LZEXJU2U-EA3S4ZFM.mjs +1 -0
  225. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-peg-WCYAUIDC-DUKZONPX.mjs +1 -0
  226. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroadDiagram-R6RP3MQ2.mjs +1 -0
  227. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/requirementDiagram-SGBABQV6.mjs +84 -0
  228. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/sankeyDiagram-IM6M5BOW.mjs +40 -0
  229. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/sequenceDiagram-FGJOIFJ2.mjs +169 -0
  230. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/sizeCapture-ERV5D3W5.mjs +1 -0
  231. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-v2-6KDGCZ5J.mjs +291 -0
  232. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/swimlanes-OJBUSBEF.mjs +1 -0
  233. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/swimlanesDiagram-LNXYTHDW.mjs +8 -0
  234. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/timeline-definition-7G6YQRTY.mjs +120 -0
  235. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/treeView-Q6P3EWNA-AABPVERA.mjs +1 -0
  236. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/treemap-WGGIJYW6-F2JSY3V3.mjs +1 -0
  237. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/usecaseDiagram-6Y6TMLBO.mjs +383 -0
  238. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/vennDiagram-6RODYPCK.mjs +34 -0
  239. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/wardley-WFR3VGLG-EIT5NWVN.mjs +1 -0
  240. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/wardleyDiagram-RFZKLAPA.mjs +78 -0
  241. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/xychartDiagram-TWWI2QJZ.mjs +7 -0
  242. rdstudio/web/vendor/mermaid/mermaid.esm.min.mjs +14 -0
  243. rdstudio/web/vendor/texmath.js +368 -0
  244. rdstudio/web/vendor/vega/vega-embed.min.js +7 -0
  245. rdstudio/web/vendor/vega/vega-lite.min.js +2 -0
  246. rdstudio/web/vendor/vega/vega.min.js +1 -0
  247. rdstudio-0.2.0.dist-info/METADATA +201 -0
  248. rdstudio-0.2.0.dist-info/RECORD +251 -0
  249. rdstudio-0.2.0.dist-info/WHEEL +4 -0
  250. rdstudio-0.2.0.dist-info/entry_points.txt +3 -0
  251. rdstudio-0.2.0.dist-info/licenses/LICENSE +21 -0
rdstudio/okf.py ADDED
@@ -0,0 +1,775 @@
1
+ """Open Knowledge Format (OKF v0.2) bundle model.
2
+
3
+ Parses a bundle directory into concepts, directories and links, derives trust
4
+ tiers and staleness, lints conformance, and generates ``index.md`` files.
5
+ The spec in ``reference/OKF_SPEC.md`` is the ground truth for everything here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import posixpath
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from datetime import date, datetime, timedelta, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import unquote
18
+
19
+ import yaml
20
+ from markdown_it import MarkdownIt
21
+
22
+ OKF_VERSION = "0.2"
23
+ RESERVED = {"index.md", "log.md"}
24
+
25
+ _SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
26
+ _LOG_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
27
+
28
+
29
+ # --------------------------------------------------------------------------- #
30
+ # Frontmatter
31
+ # --------------------------------------------------------------------------- #
32
+
33
+
34
+ class FrontmatterError(ValueError):
35
+ pass
36
+
37
+
38
+ # Frontmatter is YAML 1.2 (core schema), as in the TypeScript core: `yes`, `on`
39
+ # and `NO` are text, `010` is ten, and dates stay the text they were written as
40
+ # (to_datetime reads them). PyYAML implements YAML 1.1, so its implicit types
41
+ # are replaced; LibYAML's parser is used when available (about ten times faster).
42
+ _YAML12 = {
43
+ "tag:yaml.org,2002:bool": (r"^(?:true|True|TRUE|false|False|FALSE)$", "tTfF"),
44
+ "tag:yaml.org,2002:int": (r"^(?:[-+]?[0-9]+|0o[0-7]+|0x[0-9a-fA-F]+)$", "-+0123456789"),
45
+ "tag:yaml.org,2002:float": (r"^(?:[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?"
46
+ r"|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$", "-+0123456789."),
47
+ "tag:yaml.org,2002:null": (r"^(?:~|null|Null|NULL|)$", ["~", "n", "N", ""]),
48
+ }
49
+ _RESOLVERS: dict[Any, list] = {}
50
+ for _tag, (_pattern, _first) in _YAML12.items():
51
+ for _ch in _first:
52
+ _RESOLVERS.setdefault(_ch or None, []).append((_tag, re.compile(_pattern)))
53
+
54
+
55
+ class _Loader(getattr(yaml, "CSafeLoader", yaml.SafeLoader)): # type: ignore[misc]
56
+ yaml_implicit_resolvers = _RESOLVERS
57
+
58
+
59
+ def _construct_int(loader: yaml.SafeLoader, node: yaml.ScalarNode) -> int:
60
+ text = str(loader.construct_scalar(node))
61
+ if text.startswith("0o"):
62
+ return int(text[2:], 8)
63
+ if text.startswith("0x"):
64
+ return int(text[2:], 16)
65
+ return int(text, 10)
66
+
67
+
68
+ _Loader.add_constructor("tag:yaml.org,2002:int", _construct_int)
69
+
70
+
71
+ def split_frontmatter(text: str) -> tuple[dict[str, Any] | None, str]:
72
+ """Return ``(frontmatter, body)``; frontmatter is None when absent."""
73
+ if text.startswith(""):
74
+ text = text[1:]
75
+ if not text.startswith("---"):
76
+ return None, text
77
+ lines = text.split("\n")
78
+ if lines[0].strip() != "---":
79
+ return None, text
80
+ for i in range(1, len(lines)):
81
+ if lines[i].rstrip() == "---":
82
+ raw = "\n".join(lines[1:i]) + "\n" # with its last line break, as in the file
83
+ body = "\n".join(lines[i + 1 :])
84
+ try:
85
+ meta = yaml.load(raw, Loader=_Loader) if raw.strip() else {}
86
+ except yaml.YAMLError as exc:
87
+ raise FrontmatterError(f"unparseable YAML frontmatter: {exc}") from exc
88
+ if meta is None:
89
+ meta = {}
90
+ if not isinstance(meta, dict):
91
+ raise FrontmatterError("frontmatter is not a mapping")
92
+ return meta, body.lstrip("\n")
93
+ raise FrontmatterError("frontmatter block is not closed with '---'")
94
+
95
+
96
+ class _Dumper(yaml.SafeDumper):
97
+ # The same YAML 1.2 types as the loader, so text such as `yes` or a date is
98
+ # written plain, not quoted, and reads back unchanged.
99
+ yaml_implicit_resolvers = _RESOLVERS
100
+
101
+
102
+ def _repr_datetime(dumper: yaml.SafeDumper, value: datetime) -> yaml.Node:
103
+ return dumper.represent_scalar("tag:yaml.org,2002:str", iso(value))
104
+
105
+
106
+ def _repr_str(dumper: yaml.SafeDumper, value: str) -> yaml.Node:
107
+ style = "|" if "\n" in value else None
108
+ return dumper.represent_scalar("tag:yaml.org,2002:str", value, style=style)
109
+
110
+
111
+ def _repr_list(dumper: yaml.SafeDumper, value: list) -> yaml.Node:
112
+ flat = all(isinstance(v, (str, int, float, bool)) and "\n" not in str(v) for v in value)
113
+ return dumper.represent_sequence("tag:yaml.org,2002:seq", value, flow_style=flat and bool(value))
114
+
115
+
116
+ _Dumper.add_representer(datetime, _repr_datetime)
117
+ _Dumper.add_representer(str, _repr_str)
118
+ _Dumper.add_representer(list, _repr_list)
119
+
120
+
121
+ def dump_frontmatter(meta: dict[str, Any]) -> str:
122
+ """Serialise frontmatter, preserving key order and writing ISO 8601 UTC times."""
123
+ return yaml.dump(meta, Dumper=_Dumper, sort_keys=False, allow_unicode=True, width=100, default_flow_style=False)
124
+
125
+
126
+ def render_concept(meta: dict[str, Any], body: str) -> str:
127
+ return f"---\n{dump_frontmatter(meta)}---\n\n{body.strip()}\n"
128
+
129
+
130
+ # --------------------------------------------------------------------------- #
131
+ # Time helpers
132
+ # --------------------------------------------------------------------------- #
133
+
134
+
135
+ def now() -> datetime:
136
+ return datetime.now(timezone.utc).replace(microsecond=0)
137
+
138
+
139
+ def iso(value: datetime) -> str:
140
+ if value.tzinfo is None:
141
+ value = value.replace(tzinfo=timezone.utc)
142
+ return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
143
+
144
+
145
+ # The date and time forms frontmatter may use (the TypeScript core accepts the
146
+ # same): a date, or a date and time with optional seconds, fraction and offset;
147
+ # no offset means UTC.
148
+ _DATETIME = re.compile(
149
+ r"^(\d{4})-(\d{2})-(\d{2})"
150
+ r"(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(?:([Zz])|([+-])(\d{2}):?(\d{2}))?)?$")
151
+
152
+
153
+ def to_datetime(value: Any) -> datetime | None:
154
+ """Read a frontmatter time (text in the forms above, or a datetime or date)
155
+ as an aware datetime; None when it is not one."""
156
+ if isinstance(value, datetime):
157
+ return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
158
+ if isinstance(value, date):
159
+ return datetime(value.year, value.month, value.day, tzinfo=timezone.utc)
160
+ if not isinstance(value, str):
161
+ return None
162
+ m = _DATETIME.match(value.strip())
163
+ if not m:
164
+ return None
165
+ y, mo, d, hh, mm, ss, frac, _z, sign, oh, om = m.groups()
166
+ try:
167
+ tz = timezone.utc
168
+ if sign:
169
+ offset = timedelta(hours=int(oh), minutes=int(om))
170
+ tz = timezone(offset if sign == "+" else -offset)
171
+ micro = int((frac or "0")[:6].ljust(6, "0"))
172
+ return datetime(int(y), int(mo), int(d), int(hh or 0), int(mm or 0), int(ss or 0), micro, tzinfo=tz)
173
+ except ValueError: # such as month 13
174
+ return None
175
+
176
+
177
+ def text(value: Any) -> str:
178
+ """A frontmatter value as text, the same in every implementation: booleans
179
+ as true/false, whole-number floats without ".0", nothing for null."""
180
+ if value is None:
181
+ return ""
182
+ if isinstance(value, bool):
183
+ return "true" if value else "false"
184
+ if isinstance(value, float) and value.is_integer():
185
+ return str(int(value))
186
+ return str(value)
187
+
188
+
189
+ def jsonable(value: Any) -> Any:
190
+ """Convert frontmatter values to JSON-safe equivalents."""
191
+ if isinstance(value, datetime):
192
+ return iso(value)
193
+ if isinstance(value, date):
194
+ return value.isoformat()
195
+ if isinstance(value, dict):
196
+ return {text(k): jsonable(v) for k, v in value.items()}
197
+ if isinstance(value, (list, tuple)):
198
+ return [jsonable(v) for v in value]
199
+ return value
200
+
201
+
202
+ # --------------------------------------------------------------------------- #
203
+ # Markdown helpers
204
+ # --------------------------------------------------------------------------- #
205
+
206
+
207
+ # Links and headings are read with markdown-it (CommonMark), the parser the
208
+ # dashboard renders with and the TypeScript core uses, so all three agree on
209
+ # what is a link: never one in code, escaped, or in an image.
210
+ _MD = MarkdownIt("commonmark")
211
+
212
+
213
+ @functools.lru_cache(maxsize=8192)
214
+ def _parse(body: str) -> tuple[tuple[Any, ...], tuple[tuple[str, str | None], ...]]:
215
+ """markdown-it's tokens for a body, and its link reference definitions."""
216
+ env: dict[str, Any] = {}
217
+ tokens = _MD.parse(body, env)
218
+ defs = tuple((d.get("href", ""), d.get("title")) for d in env.get("references", {}).values())
219
+ return tuple(tokens), defs
220
+
221
+
222
+ def slugify(text: str) -> str:
223
+ text = re.sub(r"[^\w\s-]", "", text.lower()).strip()
224
+ return re.sub(r"[\s_]+", "-", text)
225
+
226
+
227
+ @dataclass
228
+ class Heading:
229
+ level: int
230
+ text: str
231
+ slug: str
232
+ line: int
233
+
234
+
235
+ def headings(body: str) -> list[Heading]:
236
+ tokens, _ = _parse(body)
237
+ out: list[Heading] = []
238
+ for i, t in enumerate(tokens):
239
+ if t.type == "heading_open":
240
+ label = tokens[i + 1].content.strip()
241
+ out.append(Heading(int(t.tag[1]), label, slugify(label), t.map[0]))
242
+ return out
243
+
244
+
245
+ def section(body: str, heading: str) -> str | None:
246
+ """Return the text under ``heading`` (matched by text or slug) up to the next
247
+ heading of the same or higher level, including the heading line itself."""
248
+ wanted = heading.strip().lstrip("#").strip().lower()
249
+ wanted_slug = slugify(wanted)
250
+ hs = headings(body)
251
+ lines = body.split("\n")
252
+ for idx, h in enumerate(hs):
253
+ if h.text.lower() == wanted or h.slug == wanted_slug:
254
+ end = len(lines)
255
+ for later in hs[idx + 1 :]:
256
+ if later.level <= h.level:
257
+ end = later.line
258
+ break
259
+ return "\n".join(lines[h.line : end]).strip()
260
+ return None
261
+
262
+
263
+ def link_targets(body: str) -> list[str]:
264
+ return [target for target, _ in link_refs(body)]
265
+
266
+
267
+ # How consequential a link is, given as the link's title:
268
+ # [tangent space](/manifolds/tangent-space.md "requires").
269
+ RATINGS = {"requires": 3, "uses": 2, "see also": 1}
270
+
271
+
272
+ def rating(title: str | None) -> str | None:
273
+ """The rating a link title names, or None (unrated, or an ordinary title)."""
274
+ key = " ".join((title or "").lower().replace("-", " ").replace("_", " ").split())
275
+ key = {"seealso": "see also"}.get(key, key)
276
+ return key if key in RATINGS else None
277
+
278
+
279
+ def link_refs(body: str) -> list[tuple[str, str | None]]:
280
+ """Link targets with their ratings: every link in order of appearance, then
281
+ each reference definition no link used."""
282
+ tokens, defs = _parse(body)
283
+ refs = [(c.attrGet("href") or "", rating(c.attrGet("title")))
284
+ for t in tokens if t.type == "inline" and t.children
285
+ for c in t.children if c.type == "link_open"]
286
+ used = {href for href, _ in refs}
287
+ refs += [(href, rating(title)) for href, title in defs if href not in used]
288
+ return refs
289
+
290
+
291
+ # --------------------------------------------------------------------------- #
292
+ # Model
293
+ # --------------------------------------------------------------------------- #
294
+
295
+
296
+ @dataclass
297
+ class Link:
298
+ target: str # concept id, or directory id with trailing '/'
299
+ kind: str # "concept" | "directory"
300
+ broken: bool = False
301
+ rel: str | None = None # "requires" | "uses" | "see also" | None (unrated)
302
+
303
+
304
+ @dataclass
305
+ class Concept:
306
+ id: str # bundle path without .md, e.g. "design/overview"
307
+ path: str # bundle path, e.g. "design/overview.md"
308
+ meta: dict[str, Any]
309
+ body: str
310
+ mtime: float = 0.0
311
+ links: list[Link] = field(default_factory=list)
312
+ errors: list[str] = field(default_factory=list)
313
+
314
+ @property
315
+ def directory(self) -> str:
316
+ return posixpath.dirname(self.id)
317
+
318
+ @property
319
+ def type(self) -> str:
320
+ t = self.meta.get("type")
321
+ return text(t) if t not in (None, "") else ""
322
+
323
+ @property
324
+ def title(self) -> str:
325
+ t = self.meta.get("title")
326
+ if t:
327
+ return text(t)
328
+ stem = posixpath.basename(self.id)
329
+ return stem.replace("-", " ").replace("_", " ").strip().capitalize() or stem
330
+
331
+ @property
332
+ def description(self) -> str:
333
+ return text(self.meta.get("description") or "")
334
+
335
+ @property
336
+ def tags(self) -> list[str]:
337
+ tags = self.meta.get("tags") or []
338
+ if isinstance(tags, str):
339
+ tags = [tags]
340
+ return [text(t) for t in tags if t is not None]
341
+
342
+ @property
343
+ def status(self) -> str:
344
+ return text(self.meta.get("status") or "stable")
345
+
346
+ @property
347
+ def generated(self) -> dict[str, Any]:
348
+ g = self.meta.get("generated")
349
+ return g if isinstance(g, dict) else {}
350
+
351
+ @property
352
+ def generated_at(self) -> datetime | None:
353
+ return to_datetime(self.generated.get("at"))
354
+
355
+ @property
356
+ def verified(self) -> list[dict[str, Any]]:
357
+ v = self.meta.get("verified")
358
+ if isinstance(v, dict):
359
+ return [v]
360
+ if isinstance(v, list):
361
+ return [e for e in v if isinstance(e, dict)]
362
+ return []
363
+
364
+ @property
365
+ def trust(self) -> str:
366
+ """``unverified`` | ``machine-confirmed`` | ``human-reviewed`` (OKF §5.3)."""
367
+ events = self.verified
368
+ if not events:
369
+ return "unverified"
370
+ if any(text(e.get("by")).startswith("human:") for e in events):
371
+ return "human-reviewed"
372
+ return "machine-confirmed"
373
+
374
+ @property
375
+ def last_human_verification(self) -> datetime | None:
376
+ times = [
377
+ to_datetime(e.get("at"))
378
+ for e in self.verified
379
+ if text(e.get("by")).startswith("human:")
380
+ ]
381
+ times = [t for t in times if t is not None]
382
+ return max(times) if times else None
383
+
384
+ @property
385
+ def verification_stale(self) -> bool:
386
+ """Human-reviewed, but meaningfully changed since the latest human check."""
387
+ verified_at = self.last_human_verification
388
+ generated_at = self.generated_at
389
+ return bool(verified_at and generated_at and generated_at > verified_at)
390
+
391
+ @property
392
+ def content_stale(self) -> bool:
393
+ """``stale_after`` has passed (OKF §5.5)."""
394
+ at = to_datetime(self.meta.get("stale_after"))
395
+ return bool(at and now() >= at)
396
+
397
+ def summary(self) -> dict[str, Any]:
398
+ return {
399
+ "id": self.id,
400
+ "path": self.path,
401
+ "title": self.title,
402
+ "type": self.type,
403
+ "description": self.description,
404
+ "tags": self.tags,
405
+ "status": self.status,
406
+ "trust": self.trust,
407
+ "verification_stale": self.verification_stale,
408
+ "content_stale": self.content_stale,
409
+ }
410
+
411
+
412
+ @dataclass
413
+ class Directory:
414
+ id: str # "" for the root, otherwise "design" / "design/sub"
415
+ concepts: list[str] = field(default_factory=list)
416
+ children: list[str] = field(default_factory=list)
417
+ has_index: bool = False
418
+ has_log: bool = False
419
+
420
+ @property
421
+ def name(self) -> str:
422
+ return posixpath.basename(self.id) if self.id else ""
423
+
424
+
425
+ @dataclass
426
+ class Issue:
427
+ path: str
428
+ level: str # "error" | "warning"
429
+ message: str
430
+ # A stable name for the kind of issue, which other implementations match
431
+ # (fixtures/); the message is for people and may differ.
432
+ code: str = ""
433
+
434
+
435
+ class Bundle:
436
+ """An OKF bundle loaded from disk."""
437
+
438
+ def __init__(self, root: Path, name: str = "project"):
439
+ self.root = Path(root)
440
+ self.name = name
441
+ self.concepts: dict[str, Concept] = {}
442
+ self.directories: dict[str, Directory] = {}
443
+ self.issues: list[Issue] = []
444
+ self.root_meta: dict[str, Any] = {}
445
+ # Derived views, computed on first use: a bundle does not change once loaded.
446
+ self._prereq: dict[str, dict[str, int]] | None = None
447
+ self._requires: dict[str, list[str]] | None = None
448
+ self._backlinks: dict[str, list[str]] | None = None
449
+
450
+ # ----------------------------------------------------------------- load
451
+
452
+ @classmethod
453
+ def load(cls, root: Path, name: str = "project") -> "Bundle":
454
+ bundle = cls(root, name)
455
+ bundle._scan()
456
+ return bundle
457
+
458
+ def _scan(self) -> None:
459
+ self.directories[""] = Directory("")
460
+ if not self.root.is_dir():
461
+ return
462
+ for path in sorted(self.root.rglob("*")):
463
+ rel = path.relative_to(self.root).as_posix()
464
+ if any(part.startswith(".") for part in rel.split("/")):
465
+ continue
466
+ if path.is_dir():
467
+ self._ensure_dir(rel)
468
+ continue
469
+ if path.suffix != ".md":
470
+ continue
471
+ directory = posixpath.dirname(rel)
472
+ self._ensure_dir(directory)
473
+ if path.name == "index.md":
474
+ self.directories[directory].has_index = True
475
+ self._check_index(path, rel, directory)
476
+ continue
477
+ if path.name == "log.md":
478
+ self.directories[directory].has_log = True
479
+ self._check_log(path, rel)
480
+ continue
481
+ self._load_concept(path, rel)
482
+ for concept in self.concepts.values():
483
+ links = []
484
+ for target, rel in link_refs(concept.body):
485
+ link = self._resolve(concept, target)
486
+ if link is not None:
487
+ link.rel = rel
488
+ links.append(link)
489
+ concept.links = links
490
+ for link in concept.links:
491
+ if link.broken:
492
+ self.issues.append(Issue(concept.path, "warning", f"broken link to {link.target}", "broken-link"))
493
+ for group in self.requires_cycles():
494
+ first = self.concepts[group[0]]
495
+ self.issues.append(Issue(first.path, "warning", "requires cycle: " + ", ".join(group) + " require each other", "requires-cycle"))
496
+
497
+ def _ensure_dir(self, directory: str) -> None:
498
+ if directory in self.directories:
499
+ return
500
+ parent = posixpath.dirname(directory)
501
+ self._ensure_dir(parent)
502
+ self.directories[directory] = Directory(directory)
503
+ self.directories[parent].children.append(directory)
504
+
505
+ def _load_concept(self, path: Path, rel: str) -> None:
506
+ text = path.read_text(encoding="utf-8")
507
+ cid = rel[:-3]
508
+ try:
509
+ meta, body = split_frontmatter(text)
510
+ except FrontmatterError as exc:
511
+ self.issues.append(Issue(rel, "error", str(exc), "frontmatter-invalid"))
512
+ meta, body = {}, text
513
+ concept = Concept(cid, rel, meta or {}, body, path.stat().st_mtime)
514
+ if meta is None:
515
+ self.issues.append(Issue(rel, "error", "missing YAML frontmatter", "frontmatter-missing"))
516
+ elif not concept.type:
517
+ self.issues.append(Issue(rel, "error", "frontmatter has no non-empty 'type'", "type-missing"))
518
+ for key in ("generated",):
519
+ if key in concept.meta and "by" not in concept.generated:
520
+ self.issues.append(Issue(rel, "warning", "'generated' should record 'by'", "generated-without-by"))
521
+ for event in concept.verified:
522
+ if "by" not in event:
523
+ self.issues.append(Issue(rel, "warning", "'verified' entry without 'by'", "verified-without-by"))
524
+ self.concepts[cid] = concept
525
+ self.directories[posixpath.dirname(rel)].concepts.append(cid)
526
+
527
+ def _check_index(self, path: Path, rel: str, directory: str) -> None:
528
+ try:
529
+ meta, _ = split_frontmatter(path.read_text(encoding="utf-8"))
530
+ except FrontmatterError as exc:
531
+ self.issues.append(Issue(rel, "error", str(exc), "frontmatter-invalid"))
532
+ return
533
+ if meta is None:
534
+ return
535
+ if directory != "":
536
+ self.issues.append(Issue(rel, "error", "index.md below the root must not have frontmatter", "index-frontmatter"))
537
+ elif set(meta) - {"okf_version"}:
538
+ self.issues.append(Issue(rel, "error", "root index.md frontmatter may only carry okf_version", "root-index-frontmatter"))
539
+ else:
540
+ self.root_meta = meta
541
+
542
+ def _check_log(self, path: Path, rel: str) -> None:
543
+ for line in path.read_text(encoding="utf-8").split("\n"):
544
+ if line.startswith("## ") and not _LOG_DATE.match(line[3:].strip()):
545
+ self.issues.append(Issue(rel, "warning", f"log heading is not YYYY-MM-DD: {line.strip()}", "log-heading-date"))
546
+
547
+ def _resolve(self, concept: Concept, target: str) -> Link | None:
548
+ if not target or target.startswith("#") or _SCHEME.match(target):
549
+ return None
550
+ target = unquote(target.split("#", 1)[0].split("?", 1)[0])
551
+ if not target:
552
+ return None
553
+ if target.startswith("/"):
554
+ joined = target.lstrip("/")
555
+ else:
556
+ joined = posixpath.join(concept.directory, target)
557
+ norm = posixpath.normpath(joined) if joined else "."
558
+ if norm.startswith(".."):
559
+ return None # outside the bundle
560
+ if norm == ".":
561
+ norm = ""
562
+ if target.endswith("/") or norm in self.directories:
563
+ return Link(norm + "/", "directory", norm not in self.directories)
564
+ if norm.endswith("/index.md") or norm == "index.md":
565
+ d = posixpath.dirname(norm)
566
+ return Link(d + "/", "directory", d not in self.directories)
567
+ if norm.endswith(".md"):
568
+ cid = norm[:-3]
569
+ return Link(cid, "concept", cid not in self.concepts)
570
+ return None # a non-markdown file: not a concept link
571
+
572
+ # ------------------------------------------------------------- queries
573
+
574
+ def requires_graph(self) -> dict[str, list[str]]:
575
+ """Each concept's direct prerequisites: the concepts it links to as ``requires``."""
576
+ if self._requires is None:
577
+ self._requires = self._requires_graph()
578
+ return self._requires
579
+
580
+ def _requires_graph(self) -> dict[str, list[str]]:
581
+ return {cid: sorted({l.target for l in c.links if l.rel == "requires" and l.kind == "concept"
582
+ and not l.broken and l.target != cid and l.target in self.concepts})
583
+ for cid, c in self.concepts.items()}
584
+
585
+ def _components(self) -> list[list[str]]:
586
+ """Strongly connected components of the requires graph (Tarjan's algorithm),
587
+ singletons included; a component is a group that require each other."""
588
+ graph = self.requires_graph()
589
+ index: dict[str, int] = {}
590
+ low: dict[str, int] = {}
591
+ stack: list[str] = []
592
+ on_stack: set[str] = set()
593
+ groups: list[list[str]] = []
594
+ counter = 0
595
+
596
+ def visit(v: str) -> None:
597
+ nonlocal counter
598
+ index[v] = low[v] = counter
599
+ counter += 1
600
+ stack.append(v)
601
+ on_stack.add(v)
602
+ for w in graph[v]:
603
+ if w not in index:
604
+ visit(w)
605
+ low[v] = min(low[v], low[w])
606
+ elif w in on_stack:
607
+ low[v] = min(low[v], index[w])
608
+ if low[v] == index[v]:
609
+ group = []
610
+ while True:
611
+ w = stack.pop()
612
+ on_stack.discard(w)
613
+ group.append(w)
614
+ if w == v:
615
+ break
616
+ groups.append(sorted(group))
617
+
618
+ for v in sorted(graph):
619
+ if v not in index:
620
+ visit(v)
621
+ return groups
622
+
623
+ def requires_cycles(self) -> list[list[str]]:
624
+ """Groups of concepts that require each other, directly or through a chain."""
625
+ return sorted(g for g in self._components() if len(g) > 1)
626
+
627
+ def prerequisite_order(self) -> dict[str, dict[str, int]]:
628
+ """For every concept, its place in a reading order (``order``) and how advanced
629
+ it is (``depth``: the longest chain of prerequisites below it).
630
+
631
+ The order is a topological sort of the requires graph with cycles treated
632
+ as one step; among the concepts that are ready, it prefers staying in the
633
+ folder just read, then shallower concepts, then titles."""
634
+ if self._prereq is not None:
635
+ return self._prereq
636
+ graph = self.requires_graph()
637
+ comps = self._components()
638
+ comp_of = {cid: i for i, g in enumerate(comps) for cid in g}
639
+ needs = [sorted({comp_of[w] for v in g for w in graph[v]} - {i}) for i, g in enumerate(comps)]
640
+ depth: list[int] = [-1] * len(comps)
641
+ # Tarjan emits a component only after everything it reaches, so this order is safe.
642
+ for i in range(len(comps)):
643
+ depth[i] = 1 + max((depth[j] for j in needs[i]), default=-1)
644
+ waiting = [len(n) for n in needs]
645
+ unlocks: list[list[int]] = [[] for _ in comps]
646
+ for i, n in enumerate(needs):
647
+ for j in n:
648
+ unlocks[j].append(i)
649
+ ready = {i for i, w in enumerate(waiting) if w == 0}
650
+ title = {i: min(self.concepts[c].title.lower() for c in g) for i, g in enumerate(comps)}
651
+ folder = {i: self.concepts[comps[i][0]].directory for i in range(len(comps))}
652
+
653
+ parts = {i: tuple(f.split("/")) if f else () for i, f in folder.items()}
654
+
655
+ def shared(a: tuple[str, ...], b: tuple[str, ...]) -> int:
656
+ n = 0
657
+ for x, y in zip(a, b):
658
+ if x != y:
659
+ break
660
+ n += 1
661
+ return n
662
+
663
+ out: dict[str, dict[str, int]] = {}
664
+ last = None
665
+ while ready:
666
+ near = parts[last] if last is not None else ()
667
+ i = min(ready, key=lambda k: (-shared(parts[k], near), depth[k], folder[k], title[k]))
668
+ ready.discard(i)
669
+ for cid in sorted(comps[i], key=lambda c: self.concepts[c].title.lower()):
670
+ out[cid] = {"order": len(out), "depth": depth[i]}
671
+ last = i
672
+ for k in unlocks[i]:
673
+ waiting[k] -= 1
674
+ if waiting[k] == 0:
675
+ ready.add(k)
676
+ self._prereq = out
677
+ return out
678
+
679
+ def prerequisites(self, cid: str) -> list[str]:
680
+ """Everything ``cid`` requires, directly or through a chain, in reading order
681
+ (``cid`` itself excluded)."""
682
+ graph = self.requires_graph()
683
+ seen: set[str] = set()
684
+ todo = list(graph.get(cid, []))
685
+ while todo:
686
+ v = todo.pop()
687
+ if v not in seen:
688
+ seen.add(v)
689
+ todo.extend(graph[v])
690
+ seen.discard(cid)
691
+ order = self.prerequisite_order()
692
+ return sorted(seen, key=lambda c: order[c]["order"])
693
+
694
+ def backlinks(self, cid: str) -> list[str]:
695
+ """The concepts that link to ``cid``."""
696
+ if self._backlinks is None:
697
+ index: dict[str, set[str]] = {}
698
+ for c in self.concepts.values():
699
+ for l in c.links:
700
+ index.setdefault(l.target, set()).add(c.id)
701
+ self._backlinks = {target: sorted(ids) for target, ids in index.items()}
702
+ return self._backlinks.get(cid, [])
703
+
704
+ def lint(self) -> list[Issue]:
705
+ from .procedures import lint as lint_procedures # procedures builds on this module
706
+
707
+ return list(self.issues) + [Issue(path, "error", msg, "procedure") for path, msg in lint_procedures(self)]
708
+
709
+ def resolve_id(self, ref: str) -> str | None:
710
+ """Accept an id, a bundle path, or a bundle-absolute link."""
711
+ ref = ref.strip().lstrip("/")
712
+ if ref.endswith(".md"):
713
+ ref = ref[:-3]
714
+ return ref if ref in self.concepts else None
715
+
716
+ # ------------------------------------------------------------- indexes
717
+
718
+ def overview_for(self, directory: str) -> Concept | None:
719
+ for cid in self.directories[directory].concepts:
720
+ c = self.concepts[cid]
721
+ if c.type.lower() == "overview":
722
+ return c
723
+ return None
724
+
725
+ def render_index(self, directory: str) -> str:
726
+ """Generate an OKF §8 index for ``directory``."""
727
+ d = self.directories[directory]
728
+ groups: dict[str, list[Concept]] = {}
729
+ for cid in d.concepts:
730
+ c = self.concepts[cid]
731
+ groups.setdefault(c.type or "Concept", []).append(c)
732
+ parts: list[str] = []
733
+ if directory == "":
734
+ parts.append(f"---\nokf_version: \"{OKF_VERSION}\"\n---\n")
735
+ for type_name in sorted(groups, key=lambda t: (t.lower() != "overview", t.lower())):
736
+ items = sorted(groups[type_name], key=lambda c: c.title.lower())
737
+ lines = [f"# {type_name}", ""]
738
+ for c in items:
739
+ href = posixpath.basename(c.path)
740
+ desc = f" - {_one_line(c.description)}" if c.description else ""
741
+ lines.append(f"* [{_escape(c.title)}]({href}){desc}")
742
+ parts.append("\n".join(lines) + "\n")
743
+ if d.children:
744
+ lines = ["# Directories", ""]
745
+ for child in sorted(d.children):
746
+ overview = self.overview_for(child)
747
+ desc = f" - {_one_line(overview.description)}" if overview and overview.description else ""
748
+ name = posixpath.basename(child)
749
+ lines.append(f"* [{name}]({name}/){desc}")
750
+ parts.append("\n".join(lines) + "\n")
751
+ if not groups and not d.children:
752
+ parts.append("# Contents\n")
753
+ return "\n".join(parts).rstrip() + "\n"
754
+
755
+ def write_indexes(self) -> list[str]:
756
+ """Write every directory's index.md; return the paths that changed."""
757
+ changed: list[str] = []
758
+ for directory in sorted(self.directories):
759
+ target = self.root / directory / "index.md" if directory else self.root / "index.md"
760
+ content = self.render_index(directory)
761
+ current = target.read_text(encoding="utf-8") if target.exists() else None
762
+ if current != content:
763
+ target.parent.mkdir(parents=True, exist_ok=True)
764
+ target.write_text(content, encoding="utf-8")
765
+ changed.append(target.relative_to(self.root).as_posix())
766
+ self.directories[directory].has_index = True
767
+ return changed
768
+
769
+
770
+ def _one_line(text: str) -> str:
771
+ return " ".join(text.split())
772
+
773
+
774
+ def _escape(text: str) -> str:
775
+ return text.replace("[", r"\[").replace("]", r"\]")