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.
- rdstudio/__init__.py +3 -0
- rdstudio/_node/rdstudio.mjs +59351 -0
- rdstudio/brief.py +46 -0
- rdstudio/build.py +218 -0
- rdstudio/classify.py +131 -0
- rdstudio/cli.py +369 -0
- rdstudio/config.py +115 -0
- rdstudio/gitlog.py +92 -0
- rdstudio/launcher.py +63 -0
- rdstudio/learner.py +187 -0
- rdstudio/mcp_server.py +313 -0
- rdstudio/okf.py +775 -0
- rdstudio/procedures.py +233 -0
- rdstudio/references.py +228 -0
- rdstudio/reports.py +75 -0
- rdstudio/scaffold.py +227 -0
- rdstudio/scopes.py +162 -0
- rdstudio/search.py +149 -0
- rdstudio/serve.py +214 -0
- rdstudio/store.py +161 -0
- rdstudio/templates/agents/critic.md +20 -0
- rdstudio/templates/agents/librarian.md +21 -0
- rdstudio/templates/agents/searcher.md +21 -0
- rdstudio/templates/agents_section.md +31 -0
- rdstudio/templates/claude_section.md +3 -0
- rdstudio/templates/knowledge/overview.md +20 -0
- rdstudio/templates/knowledge/tasks/bootstrap-knowledge-base.md +45 -0
- rdstudio/templates/skills/decision/SKILL.md +47 -0
- rdstudio/templates/skills/handoff/SKILL.md +18 -0
- rdstudio/templates/skills/ingest-ref/SKILL.md +32 -0
- rdstudio/templates/skills/lint-okf/SKILL.md +15 -0
- rdstudio/templates/skills/promote/SKILL.md +25 -0
- rdstudio/templates/skills/question/SKILL.md +33 -0
- rdstudio/templates/skills/record-okf/SKILL.md +61 -0
- rdstudio/templates/skills/report/SKILL.md +46 -0
- rdstudio/templates/skills/report/template.html +74 -0
- rdstudio/templates/skills/search-okf/SKILL.md +36 -0
- rdstudio/templates/skills/task/SKILL.md +51 -0
- rdstudio/web/app.js +140 -0
- rdstudio/web/fonts.css +48 -0
- rdstudio/web/icon-192.png +0 -0
- rdstudio/web/icon-512.png +0 -0
- rdstudio/web/index.html +109 -0
- rdstudio/web/js/data.js +125 -0
- rdstudio/web/js/diagrams.js +116 -0
- rdstudio/web/js/graph.js +396 -0
- rdstudio/web/js/knowledge.js +212 -0
- rdstudio/web/js/layout-worker.js +15 -0
- rdstudio/web/js/layout.js +226 -0
- rdstudio/web/js/learn.js +83 -0
- rdstudio/web/js/map.js +1293 -0
- rdstudio/web/js/markdown.js +173 -0
- rdstudio/web/js/pages.js +219 -0
- rdstudio/web/js/procedures.js +262 -0
- rdstudio/web/js/settings.js +95 -0
- rdstudio/web/js/util.js +99 -0
- rdstudio/web/report.css +35 -0
- rdstudio/web/report.js +104 -0
- rdstudio/web/style.css +566 -0
- rdstudio/web/sw.js +166 -0
- rdstudio/web/themes/cyber.css +139 -0
- rdstudio/web/themes/map.css +133 -0
- rdstudio/web/themes/notebook.css +134 -0
- rdstudio/web/themes/space.css +135 -0
- rdstudio/web/themes/studio.css +110 -0
- rdstudio/web/vendor/d3.min.js +2 -0
- rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-ext-wght-italic.woff2 +0 -0
- rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-ext-wght-normal.woff2 +0 -0
- rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-wght-italic.woff2 +0 -0
- rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-wght-normal.woff2 +0 -0
- rdstudio/web/vendor/fonts/caveat-latin-wght-normal.woff2 +0 -0
- rdstudio/web/vendor/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- rdstudio/web/vendor/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- rdstudio/web/vendor/fonts/ibm-plex-sans-latin-wght-italic.woff2 +0 -0
- rdstudio/web/vendor/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- rdstudio/web/vendor/fonts/inter-latin-opsz-italic.woff2 +0 -0
- rdstudio/web/vendor/fonts/inter-latin-opsz-normal.woff2 +0 -0
- rdstudio/web/vendor/fonts/jetbrains-mono-latin-wght-italic.woff2 +0 -0
- rdstudio/web/vendor/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- rdstudio/web/vendor/fonts/literata-latin-ext-opsz-italic.woff2 +0 -0
- rdstudio/web/vendor/fonts/literata-latin-ext-opsz-normal.woff2 +0 -0
- rdstudio/web/vendor/fonts/literata-latin-opsz-italic.woff2 +0 -0
- rdstudio/web/vendor/fonts/literata-latin-opsz-normal.woff2 +0 -0
- rdstudio/web/vendor/fonts/source-sans-3-latin-wght-italic.woff2 +0 -0
- rdstudio/web/vendor/fonts/source-sans-3-latin-wght-normal.woff2 +0 -0
- rdstudio/web/vendor/fonts/source-serif-4-latin-opsz-italic.woff2 +0 -0
- rdstudio/web/vendor/fonts/source-serif-4-latin-opsz-normal.woff2 +0 -0
- rdstudio/web/vendor/hljs/github-dark.min.css +10 -0
- rdstudio/web/vendor/hljs/github.min.css +10 -0
- rdstudio/web/vendor/hljs/highlight.min.js +1262 -0
- rdstudio/web/vendor/hljs/julia.min.js +18 -0
- rdstudio/web/vendor/hljs/latex.min.js +33 -0
- rdstudio/web/vendor/hljs/matlab.min.js +15 -0
- rdstudio/web/vendor/katex/auto-render.min.js +1 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- rdstudio/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- rdstudio/web/vendor/katex/katex.min.css +1 -0
- rdstudio/web/vendor/katex/katex.min.js +1 -0
- rdstudio/web/vendor/licenses/atkinson-hyperlegible-next-OFL.txt +93 -0
- rdstudio/web/vendor/licenses/caveat-OFL.txt +93 -0
- rdstudio/web/vendor/licenses/d3-7.9.0.txt +13 -0
- rdstudio/web/vendor/licenses/highlightjs-cdn-assets-11.12.0.txt +29 -0
- rdstudio/web/vendor/licenses/ibm-plex-mono-OFL.txt +93 -0
- rdstudio/web/vendor/licenses/ibm-plex-sans-OFL.txt +93 -0
- rdstudio/web/vendor/licenses/inter-OFL.txt +93 -0
- rdstudio/web/vendor/licenses/jetbrains-mono-OFL.txt +93 -0
- rdstudio/web/vendor/licenses/katex-0.18.7.txt +21 -0
- rdstudio/web/vendor/licenses/literata-OFL.txt +93 -0
- rdstudio/web/vendor/licenses/markdown-it-15.0.2.txt +22 -0
- rdstudio/web/vendor/licenses/markdown-it-footnote-4.0.0.txt +22 -0
- rdstudio/web/vendor/licenses/markdown-it-texmath-1.0.0.txt +21 -0
- rdstudio/web/vendor/licenses/mermaid-12.0.0.txt +21 -0
- rdstudio/web/vendor/licenses/source-sans-3-OFL.txt +93 -0
- rdstudio/web/vendor/licenses/source-serif-4-OFL.txt +93 -0
- rdstudio/web/vendor/licenses/vega-6.4.0.txt +27 -0
- rdstudio/web/vendor/licenses/vega-embed-7.2.0.txt +27 -0
- rdstudio/web/vendor/licenses/vega-lite-6.4.3.txt +27 -0
- rdstudio/web/vendor/markdown-it-footnote.min.js +2 -0
- rdstudio/web/vendor/markdown-it.min.js +14 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/abnfDiagram-DGLNOSUI.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/architecture-7GRP2DOG-HF4J73XS.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/architectureDiagram-IU6H5EVY.mjs +36 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/blockDiagram-FSX6DT2V.mjs +140 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/c4Diagram-YNRVYILN.mjs +38 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-22SYA5HA.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-2FTYKNOQ.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-36VM3LGA.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-3SEI7E7V.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-3T2MNPEU.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-44AU3KAP.mjs +174 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-4MZOTL6B.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-52IMQVNL.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-5NYYYZGL.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-BOZ75642.mjs +15 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-CVBX6EWK.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-D36F7FYG.mjs +14 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-DEGLBX6O.mjs +213 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-F6PIGZHG.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-FE53TMRB.mjs +133 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-FNR2RGIF.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-GDOATIN3.mjs +32 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-H7BX327W.mjs +2 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-HI3EPXIC.mjs +2 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-JCBDXJ5W.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-JGXDHUYS.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-L2B47AAZ.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-LIEGGFIX.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-LLWYPJEZ.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MFYRHTYG.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MIE3LDYP.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MLQVGYRW.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MNT3HHAC.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-NRRTYJAG.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-OCMJQKYE.mjs +10 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-PKJCTQEK.mjs +339 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-QADHU5CF.mjs +2 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-QVIEFLGF.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-QX7LGXZU.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-R2KH53MI.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-RXM7Z7NK.mjs +2 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-S4TE5DX6.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-SDIBIOXT.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-TDFULVG6.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-TECWHJ6D.mjs +62 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-TINYC7RT.mjs +70 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-V3KJR4XU.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-VTMXW4ES.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-Y5NT6LW5.mjs +88 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-Y5O5OPKC.mjs +3 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-YZG4BXY6.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZZ52EIBP.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-v2-VV4INRHB.mjs +217 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/cose-bilkent-MSIMRU2B.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/cynefin-OW5HDTMX-KMPDV6GS.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/cynefinDiagram-KTGLXFXK.mjs +62 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/dagre-QJJCSHY4.mjs +4 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-2ZIM67RP.mjs +3 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-FYHH5O6R.mjs +24 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-LYLDACMS.mjs +200 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-TYT3HRBC.mjs +24 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-XVIN6YVD.mjs +30 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-ZO2NLLYC.mjs +41 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/ebnfDiagram-TU3FGH35.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/elk-GHAXNSLK.mjs +27 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/erDiagram-XID3VV23.mjs +99 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/eventmodeling-NTZA5JFV-P5QJSWSK.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/flowDiagram-WPHQAKTU.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/ganttDiagram-QACGSKG6.mjs +292 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/gitGraph-4MIJSDKK-V5NEQQYP.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/gitGraphDiagram-EB76O4HC.mjs +106 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/info-A6RAGUB7-IGBHH2L7.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/infoDiagram-343ZKIDN.mjs +2 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/ishikawaDiagram-UEEU5D75.mjs +70 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/journeyDiagram-4QTZOVLF.mjs +139 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/kanban-definition-VEJL4RG2.mjs +89 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/katex-QVRI4OIV.mjs +257 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/mindmap-definition-C5K5ZT7T.mjs +96 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/packet-AYTQ26CC-PJTKDECJ.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/pegDiagram-FHIILVJB.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/pie-WAS4IAKB-U3JFQXZN.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/pieDiagram-S76BU6V7.mjs +39 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/quadrantDiagram-HZBLWUZE.mjs +7 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/radar-RG4KPBEZ-7T2UW32D.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-74A4TZTK-DZY7ARDL.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-abnf-HS5TGJTU-LWS535ZK.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-ebnf-LZEXJU2U-EA3S4ZFM.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-peg-WCYAUIDC-DUKZONPX.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroadDiagram-R6RP3MQ2.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/requirementDiagram-SGBABQV6.mjs +84 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/sankeyDiagram-IM6M5BOW.mjs +40 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/sequenceDiagram-FGJOIFJ2.mjs +169 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/sizeCapture-ERV5D3W5.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-v2-6KDGCZ5J.mjs +291 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/swimlanes-OJBUSBEF.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/swimlanesDiagram-LNXYTHDW.mjs +8 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/timeline-definition-7G6YQRTY.mjs +120 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/treeView-Q6P3EWNA-AABPVERA.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/treemap-WGGIJYW6-F2JSY3V3.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/usecaseDiagram-6Y6TMLBO.mjs +383 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/vennDiagram-6RODYPCK.mjs +34 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/wardley-WFR3VGLG-EIT5NWVN.mjs +1 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/wardleyDiagram-RFZKLAPA.mjs +78 -0
- rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/xychartDiagram-TWWI2QJZ.mjs +7 -0
- rdstudio/web/vendor/mermaid/mermaid.esm.min.mjs +14 -0
- rdstudio/web/vendor/texmath.js +368 -0
- rdstudio/web/vendor/vega/vega-embed.min.js +7 -0
- rdstudio/web/vendor/vega/vega-lite.min.js +2 -0
- rdstudio/web/vendor/vega/vega.min.js +1 -0
- rdstudio-0.2.0.dist-info/METADATA +201 -0
- rdstudio-0.2.0.dist-info/RECORD +251 -0
- rdstudio-0.2.0.dist-info/WHEEL +4 -0
- rdstudio-0.2.0.dist-info/entry_points.txt +3 -0
- 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"\]")
|