@shortlink-org/portolan 0.1.0 → 0.2.1

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 (381) hide show
  1. package/README.md +69 -205
  2. package/cli/init.mjs +285 -0
  3. package/cli/init.test.mjs +158 -0
  4. package/cli/portolan.mjs +37 -73
  5. package/index.html +19 -0
  6. package/package.json +5 -10
  7. package/plugins/README.md +66 -27
  8. package/plugins/portolan-go.wasm +0 -0
  9. package/portolan.json +52 -73
  10. package/public/landing/cats/bug-hunter.webp +0 -0
  11. package/public/landing/cats/compass-nap.webp +0 -0
  12. package/public/landing/cats/diagrammer.webp +0 -0
  13. package/public/landing/cats/docs-reader.webp +0 -0
  14. package/public/landing/cats/laptop.webp +0 -0
  15. package/public/landing/cats/map-inspector.webp +0 -0
  16. package/public/landing/cats/server-break.webp +0 -0
  17. package/public/landing/cats/star-mapper.webp +0 -0
  18. package/public/landing/cats/system-builder.webp +0 -0
  19. package/public/landing/cats/thread-tangle.webp +0 -0
  20. package/public/og.png +0 -0
  21. package/public/readme-header.webp +0 -0
  22. package/schema/portolan.schema.json +180 -0
  23. package/scripts/builtin-plugins.mjs +14 -0
  24. package/scripts/delivery-presets.mjs +353 -0
  25. package/scripts/gen.mjs +37 -4
  26. package/scripts/history.mjs +126 -0
  27. package/scripts/history.test.mjs +110 -0
  28. package/scripts/host-plugins/fetch-bsr.mjs +351 -0
  29. package/{plugins/fetch-bsr/options.schema.json → scripts/host-plugins/fetch-bsr.options.json} +4 -0
  30. package/scripts/host-plugins/fetch-bsr.test.mjs +212 -0
  31. package/scripts/host-plugins/fetch-csr.mjs +386 -0
  32. package/scripts/host-plugins/fetch-csr.test.mjs +178 -0
  33. package/scripts/host-plugins/fetch-git.mjs +379 -0
  34. package/scripts/host-plugins/fetch-git.test.mjs +210 -0
  35. package/scripts/local-api.mjs +27 -10
  36. package/scripts/local-api.test.mjs +85 -2
  37. package/scripts/manifest.mjs +3 -2
  38. package/scripts/package-smoke.mjs +24 -3
  39. package/scripts/plugin-host.mjs +77 -18
  40. package/scripts/plugin-host.test.mjs +50 -1
  41. package/scripts/plugin-wasm-worker.mjs +6 -3
  42. package/scripts/run-builtin.mjs +6 -26
  43. package/scripts/schema.mjs +6 -1
  44. package/src/app/App.tsx +24 -429
  45. package/src/app/Breadcrumbs.test.ts +7 -0
  46. package/src/app/Breadcrumbs.tsx +15 -2
  47. package/src/app/CatalogApp.tsx +436 -0
  48. package/src/app/Sidebar.tsx +0 -1
  49. package/src/chat/ChatPanel.tsx +98 -41
  50. package/src/chat/Composer.tsx +4 -2
  51. package/src/chat/Conversation.tsx +79 -15
  52. package/src/chat/Header.tsx +43 -9
  53. package/src/chat/Starter.tsx +64 -3
  54. package/src/chat/Steps.tsx +1 -1
  55. package/src/chat/flags.test.ts +51 -14
  56. package/src/chat/flags.ts +8 -2
  57. package/src/chat/page-context.test.ts +51 -0
  58. package/src/chat/page-context.ts +128 -0
  59. package/src/chat/prompt.test.ts +49 -1
  60. package/src/chat/prompt.ts +42 -1
  61. package/src/chat/tools.ts +7 -2
  62. package/src/chat/transport.ts +16 -4
  63. package/src/components/ProblemRow.tsx +182 -0
  64. package/src/data.ts +20 -5
  65. package/src/er/ErCanvas.tsx +1 -1
  66. package/src/graph/DependencyGraph.tsx +10 -1
  67. package/src/graph/FocusedEventGraph.tsx +1 -1
  68. package/src/graph/GraphToolbar.tsx +2 -4
  69. package/src/index.css +220 -2
  70. package/src/landing/DraggableReveal.tsx +168 -0
  71. package/src/landing/EstateGraph.tsx +28 -0
  72. package/src/landing/FlowPlayback.tsx +282 -0
  73. package/src/landing/HeroMap.tsx +128 -0
  74. package/src/landing/LandingPage.tsx +757 -0
  75. package/src/landing/ProductFrame.tsx +68 -0
  76. package/src/landing/ProductTour.tsx +371 -0
  77. package/src/landing/catalog.ts +25 -0
  78. package/src/landing/motion.tsx +52 -0
  79. package/src/lib/all-problems.ts +28 -0
  80. package/src/lib/local-api.ts +35 -1
  81. package/src/lib/setup-info.test.ts +8 -0
  82. package/src/lib/setup-info.ts +3 -2
  83. package/src/lib/source-code.ts +2 -1
  84. package/src/likec4/C4View.tsx +5 -0
  85. package/src/likec4/InteractiveView.tsx +5 -1
  86. package/src/map/ContextMapGraph.tsx +47 -20
  87. package/src/pages/ContextMap.tsx +5 -1
  88. package/src/pages/FlowDetail.tsx +24 -7
  89. package/src/pages/Overview.tsx +68 -12
  90. package/src/pages/Problems.tsx +6 -193
  91. package/src/pages/Settings.tsx +133 -108
  92. package/src/pages/settings/DeliverySettings.tsx +229 -0
  93. package/src/pages/settings/PreferencesSettings.tsx +101 -0
  94. package/src/routes.test.ts +20 -0
  95. package/src/routes.ts +13 -1
  96. package/vite.config.ts +17 -9
  97. package/catalog/enum_test.go +0 -46
  98. package/catalog/model.go +0 -932
  99. package/catalog/roundtrip_test.go +0 -185
  100. package/catalog/via_test.go +0 -38
  101. package/go.mod +0 -14
  102. package/go.sum +0 -14
  103. package/internal/commands/cargo.go +0 -154
  104. package/internal/commands/commands.go +0 -143
  105. package/internal/commands/commands_test.go +0 -323
  106. package/internal/commands/gradle.go +0 -77
  107. package/internal/commands/justfile.go +0 -82
  108. package/internal/commands/makefile.go +0 -106
  109. package/internal/commands/maven.go +0 -93
  110. package/internal/commands/packagejson.go +0 -159
  111. package/internal/commands/pyproject.go +0 -169
  112. package/internal/commands/taskfile.go +0 -101
  113. package/internal/commands/testdata/estate/.cargo/config.toml +0 -7
  114. package/internal/commands/testdata/estate/Makefile +0 -33
  115. package/internal/commands/testdata/estate/Taskfile.yml +0 -21
  116. package/internal/commands/testdata/estate/build.gradle.kts +0 -21
  117. package/internal/commands/testdata/estate/justfile +0 -20
  118. package/internal/commands/testdata/estate/package.json +0 -13
  119. package/internal/commands/testdata/estate/pom.xml +0 -29
  120. package/internal/commands/testdata/estate/pyproject.toml +0 -27
  121. package/internal/commands/testdata/estate/xtask/src/main.rs +0 -27
  122. package/internal/commands/testdata/golden/commands.json +0 -305
  123. package/internal/gohttp/analyze.go +0 -2500
  124. package/internal/gohttp/endpoints.go +0 -1067
  125. package/internal/gohttp/roots.go +0 -320
  126. package/internal/gohttp/typed.go +0 -143
  127. package/internal/goscan/constants.go +0 -85
  128. package/internal/goscan/goscan_test.go +0 -227
  129. package/internal/goscan/names.go +0 -52
  130. package/internal/goscan/parse_test.go +0 -11
  131. package/internal/goscan/source.go +0 -36
  132. package/internal/goscan/tree.go +0 -155
  133. package/internal/goscan/types.go +0 -99
  134. package/internal/wsdl/ids.go +0 -127
  135. package/internal/wsdl/ids_test.go +0 -21
  136. package/internal/wsdl/model.go +0 -62
  137. package/internal/wsdl/parse.go +0 -920
  138. package/internal/wsdl/parse_test.go +0 -133
  139. package/plugin/describe.go +0 -107
  140. package/plugin/describe_test.go +0 -114
  141. package/plugin/protocol.go +0 -117
  142. package/plugin/schematest/schematest.go +0 -126
  143. package/plugins/extract-adr/describe.go +0 -19
  144. package/plugins/extract-adr/describe_test.go +0 -11
  145. package/plugins/extract-adr/extract.go +0 -153
  146. package/plugins/extract-adr/extract_test.go +0 -350
  147. package/plugins/extract-adr/history.go +0 -99
  148. package/plugins/extract-adr/main.go +0 -65
  149. package/plugins/extract-adr/parse.go +0 -642
  150. package/plugins/extract-adr/parse_test.go +0 -405
  151. package/plugins/extract-asyncapi/describe.go +0 -19
  152. package/plugins/extract-asyncapi/describe_test.go +0 -11
  153. package/plugins/extract-asyncapi/extract.go +0 -315
  154. package/plugins/extract-asyncapi/extract_test.go +0 -197
  155. package/plugins/extract-asyncapi/main.go +0 -48
  156. package/plugins/extract-asyncapi/spec.go +0 -151
  157. package/plugins/extract-commands/describe.go +0 -19
  158. package/plugins/extract-commands/describe_test.go +0 -11
  159. package/plugins/extract-commands/extract.go +0 -79
  160. package/plugins/extract-commands/extract_test.go +0 -92
  161. package/plugins/extract-commands/main.go +0 -55
  162. package/plugins/extract-csr/avro.go +0 -258
  163. package/plugins/extract-csr/describe.go +0 -19
  164. package/plugins/extract-csr/describe_test.go +0 -11
  165. package/plugins/extract-csr/extract.go +0 -338
  166. package/plugins/extract-csr/extract_test.go +0 -374
  167. package/plugins/extract-csr/jsonschema.go +0 -343
  168. package/plugins/extract-csr/lock.go +0 -25
  169. package/plugins/extract-csr/main.go +0 -84
  170. package/plugins/extract-csr/subject.go +0 -102
  171. package/plugins/extract-flows/describe.go +0 -19
  172. package/plugins/extract-flows/describe_test.go +0 -11
  173. package/plugins/extract-flows/extract.go +0 -91
  174. package/plugins/extract-flows/main.go +0 -45
  175. package/plugins/extract-flows/parse.go +0 -593
  176. package/plugins/extract-flows/parse_test.go +0 -204
  177. package/plugins/extract-glossary/describe.go +0 -19
  178. package/plugins/extract-glossary/describe_test.go +0 -11
  179. package/plugins/extract-glossary/extract.go +0 -115
  180. package/plugins/extract-glossary/extract_test.go +0 -220
  181. package/plugins/extract-glossary/main.go +0 -59
  182. package/plugins/extract-glossary/parse.go +0 -214
  183. package/plugins/extract-glossary/parse_test.go +0 -203
  184. package/plugins/extract-go/aggregate.go +0 -214
  185. package/plugins/extract-go/client.go +0 -409
  186. package/plugins/extract-go/client_test.go +0 -266
  187. package/plugins/extract-go/describe.go +0 -19
  188. package/plugins/extract-go/describe_test.go +0 -11
  189. package/plugins/extract-go/enum.go +0 -195
  190. package/plugins/extract-go/enum_test.go +0 -82
  191. package/plugins/extract-go/event.go +0 -99
  192. package/plugins/extract-go/extract.go +0 -191
  193. package/plugins/extract-go/extract_test.go +0 -261
  194. package/plugins/extract-go/flow.go +0 -1441
  195. package/plugins/extract-go/flow_test.go +0 -609
  196. package/plugins/extract-go/httpclient.go +0 -174
  197. package/plugins/extract-go/httpclient_test.go +0 -296
  198. package/plugins/extract-go/ids.go +0 -92
  199. package/plugins/extract-go/layout.go +0 -225
  200. package/plugins/extract-go/layout_test.go +0 -125
  201. package/plugins/extract-go/lifecycle.go +0 -324
  202. package/plugins/extract-go/lifecycle_test.go +0 -108
  203. package/plugins/extract-go/main.go +0 -79
  204. package/plugins/extract-go/operation.go +0 -157
  205. package/plugins/extract-go/source.go +0 -316
  206. package/plugins/extract-go/transport.go +0 -321
  207. package/plugins/extract-go/transport_test.go +0 -145
  208. package/plugins/extract-go/wiring.go +0 -434
  209. package/plugins/extract-go-nats/describe.go +0 -19
  210. package/plugins/extract-go-nats/describe_test.go +0 -11
  211. package/plugins/extract-go-nats/extract.go +0 -177
  212. package/plugins/extract-go-nats/extract_test.go +0 -389
  213. package/plugins/extract-go-nats/index.go +0 -379
  214. package/plugins/extract-go-nats/main.go +0 -42
  215. package/plugins/extract-go-nats/resolve.go +0 -161
  216. package/plugins/extract-go-nats/sites.go +0 -224
  217. package/plugins/extract-graphql/describe.go +0 -19
  218. package/plugins/extract-graphql/describe_test.go +0 -11
  219. package/plugins/extract-graphql/extract.go +0 -433
  220. package/plugins/extract-graphql/extract_test.go +0 -256
  221. package/plugins/extract-graphql/ids.go +0 -49
  222. package/plugins/extract-graphql/lex.go +0 -237
  223. package/plugins/extract-graphql/main.go +0 -51
  224. package/plugins/extract-graphql/parse.go +0 -621
  225. package/plugins/extract-graphql/parse_test.go +0 -122
  226. package/plugins/extract-http-clients/describe.go +0 -19
  227. package/plugins/extract-http-clients/describe_test.go +0 -11
  228. package/plugins/extract-http-clients/extract.go +0 -705
  229. package/plugins/extract-http-clients/extract_test.go +0 -1263
  230. package/plugins/extract-http-clients/main.go +0 -42
  231. package/plugins/extract-openapi/describe.go +0 -19
  232. package/plugins/extract-openapi/describe_test.go +0 -11
  233. package/plugins/extract-openapi/discover.go +0 -243
  234. package/plugins/extract-openapi/extract.go +0 -526
  235. package/plugins/extract-openapi/extract_test.go +0 -545
  236. package/plugins/extract-openapi/main.go +0 -75
  237. package/plugins/extract-openapi/spec.go +0 -350
  238. package/plugins/extract-project/describe.go +0 -19
  239. package/plugins/extract-project/describe_test.go +0 -11
  240. package/plugins/extract-project/extract.go +0 -221
  241. package/plugins/extract-project/extract_test.go +0 -109
  242. package/plugins/extract-project/main.go +0 -41
  243. package/plugins/extract-proto/ast.go +0 -125
  244. package/plugins/extract-proto/consumes.go +0 -77
  245. package/plugins/extract-proto/describe.go +0 -19
  246. package/plugins/extract-proto/extract.go +0 -293
  247. package/plugins/extract-proto/extract_test.go +0 -459
  248. package/plugins/extract-proto/ids.go +0 -89
  249. package/plugins/extract-proto/ids_test.go +0 -57
  250. package/plugins/extract-proto/lex.go +0 -285
  251. package/plugins/extract-proto/main.go +0 -100
  252. package/plugins/extract-proto/module.go +0 -120
  253. package/plugins/extract-proto/parse.go +0 -720
  254. package/plugins/extract-proto/parse_test.go +0 -307
  255. package/plugins/extract-proto/provides.go +0 -236
  256. package/plugins/extract-proto/resolve.go +0 -222
  257. package/plugins/extract-proto/resolve_test.go +0 -119
  258. package/plugins/extract-redis/describe.go +0 -19
  259. package/plugins/extract-redis/describe_test.go +0 -11
  260. package/plugins/extract-redis/extract.go +0 -183
  261. package/plugins/extract-redis/extract_test.go +0 -168
  262. package/plugins/extract-redis/keyspaces.go +0 -469
  263. package/plugins/extract-redis/main.go +0 -44
  264. package/plugins/extract-river/describe.go +0 -19
  265. package/plugins/extract-river/describe_test.go +0 -11
  266. package/plugins/extract-river/extract.go +0 -507
  267. package/plugins/extract-river/extract_test.go +0 -132
  268. package/plugins/extract-river/main.go +0 -42
  269. package/plugins/extract-sql/ddl.go +0 -893
  270. package/plugins/extract-sql/ddl_test.go +0 -401
  271. package/plugins/extract-sql/describe.go +0 -19
  272. package/plugins/extract-sql/describe_test.go +0 -11
  273. package/plugins/extract-sql/layout.go +0 -221
  274. package/plugins/extract-sql/layout_test.go +0 -55
  275. package/plugins/extract-sql/lineage.go +0 -107
  276. package/plugins/extract-sql/main.go +0 -141
  277. package/plugins/extract-sql/maps.go +0 -564
  278. package/plugins/extract-sql/maps_java.go +0 -117
  279. package/plugins/extract-sql/maps_rust.go +0 -333
  280. package/plugins/extract-sql/maps_rust_test.go +0 -70
  281. package/plugins/extract-sql/maps_test.go +0 -204
  282. package/plugins/extract-sql/maps_ts.go +0 -398
  283. package/plugins/extract-sql/maps_ts_test.go +0 -136
  284. package/plugins/extract-sql/projection.go +0 -70
  285. package/plugins/extract-sql/projection_test.go +0 -50
  286. package/plugins/extract-sql/store.go +0 -420
  287. package/plugins/extract-sql/store_test.go +0 -233
  288. package/plugins/extract-sql/view.go +0 -295
  289. package/plugins/extract-watermill/describe.go +0 -19
  290. package/plugins/extract-watermill/describe_test.go +0 -11
  291. package/plugins/extract-watermill/extract.go +0 -1228
  292. package/plugins/extract-watermill/extract_test.go +0 -234
  293. package/plugins/extract-watermill/main.go +0 -41
  294. package/plugins/extract-wsdl/describe.go +0 -19
  295. package/plugins/extract-wsdl/describe_test.go +0 -11
  296. package/plugins/extract-wsdl/extract.go +0 -148
  297. package/plugins/extract-wsdl/extract_test.go +0 -45
  298. package/plugins/extract-wsdl/main.go +0 -53
  299. package/plugins/fetch-bsr/auth.go +0 -114
  300. package/plugins/fetch-bsr/bsr.go +0 -240
  301. package/plugins/fetch-bsr/cache.go +0 -66
  302. package/plugins/fetch-bsr/describe.go +0 -19
  303. package/plugins/fetch-bsr/fetch.go +0 -192
  304. package/plugins/fetch-bsr/fetch_test.go +0 -465
  305. package/plugins/fetch-bsr/lock.go +0 -71
  306. package/plugins/fetch-bsr/main.go +0 -117
  307. package/plugins/fetch-csr/auth.go +0 -95
  308. package/plugins/fetch-csr/cache.go +0 -69
  309. package/plugins/fetch-csr/describe.go +0 -19
  310. package/plugins/fetch-csr/describe_test.go +0 -11
  311. package/plugins/fetch-csr/fetch.go +0 -254
  312. package/plugins/fetch-csr/fetch_test.go +0 -494
  313. package/plugins/fetch-csr/lock.go +0 -95
  314. package/plugins/fetch-csr/main.go +0 -122
  315. package/plugins/fetch-csr/registry.go +0 -209
  316. package/plugins/fetch-git/cache.go +0 -65
  317. package/plugins/fetch-git/describe.go +0 -19
  318. package/plugins/fetch-git/describe_test.go +0 -11
  319. package/plugins/fetch-git/fetch.go +0 -181
  320. package/plugins/fetch-git/fetch_test.go +0 -332
  321. package/plugins/fetch-git/git.go +0 -169
  322. package/plugins/fetch-git/lock.go +0 -72
  323. package/plugins/fetch-git/main.go +0 -127
  324. package/plugins/fetch-git/offline.go +0 -39
  325. package/plugins/fetch-git/pin.go +0 -90
  326. package/plugins/fetch-git/pin_test.go +0 -74
  327. package/plugins/gen-backstage/describe.go +0 -17
  328. package/plugins/gen-backstage/main.go +0 -22
  329. package/plugins/gen-backstage/plugin.go +0 -473
  330. package/plugins/gen-backstage/plugin_test.go +0 -145
  331. package/plugins/gen-backstage.wasm +0 -0
  332. package/plugins/gen-markdown/adr.go +0 -162
  333. package/plugins/gen-markdown/aggregate.go +0 -362
  334. package/plugins/gen-markdown/canonical.go +0 -93
  335. package/plugins/gen-markdown/context.go +0 -90
  336. package/plugins/gen-markdown/coverage_test.go +0 -89
  337. package/plugins/gen-markdown/describe.go +0 -19
  338. package/plugins/gen-markdown/describe_test.go +0 -11
  339. package/plugins/gen-markdown/external.go +0 -71
  340. package/plugins/gen-markdown/flow.go +0 -277
  341. package/plugins/gen-markdown/glossary.go +0 -76
  342. package/plugins/gen-markdown/glossary_test.go +0 -148
  343. package/plugins/gen-markdown/llms.go +0 -302
  344. package/plugins/gen-markdown/main.go +0 -22
  345. package/plugins/gen-markdown/markdown.go +0 -254
  346. package/plugins/gen-markdown/markdown_test.go +0 -100
  347. package/plugins/gen-markdown/module.go +0 -107
  348. package/plugins/gen-markdown/plugin.go +0 -39
  349. package/plugins/gen-markdown/quality_test.go +0 -176
  350. package/plugins/gen-markdown/redis_test.go +0 -38
  351. package/plugins/gen-markdown/render.go +0 -344
  352. package/plugins/gen-markdown/render_test.go +0 -186
  353. package/plugins/gen-markdown/service.go +0 -456
  354. package/plugins/gen-markdown/source.go +0 -177
  355. package/plugins/gen-markdown/store.go +0 -201
  356. package/plugins/gen-markdown.wasm +0 -0
  357. package/plugins/gen-mermaid/describe.go +0 -17
  358. package/plugins/gen-mermaid/main.go +0 -22
  359. package/plugins/gen-mermaid/plugin.go +0 -104
  360. package/plugins/gen-mermaid/plugin_test.go +0 -33
  361. package/plugins/gen-mermaid.wasm +0 -0
  362. package/plugins/openapi/ids.go +0 -261
  363. package/plugins/openapi/ids_test.go +0 -98
  364. package/plugins/verify-codeowners/describe.go +0 -19
  365. package/plugins/verify-codeowners/describe_test.go +0 -11
  366. package/plugins/verify-codeowners/main.go +0 -75
  367. package/plugins/verify-codeowners/match.go +0 -85
  368. package/plugins/verify-codeowners/match_test.go +0 -47
  369. package/plugins/verify-codeowners/owners.go +0 -164
  370. package/plugins/verify-codeowners/owners_test.go +0 -225
  371. package/plugins/verify-codeowners/parse.go +0 -90
  372. package/plugins/verify-codeowners/parse_test.go +0 -62
  373. package/plugins/verify-otel/describe.go +0 -19
  374. package/plugins/verify-otel/describe_test.go +0 -11
  375. package/plugins/verify-otel/main.go +0 -53
  376. package/plugins/verify-otel/match.go +0 -336
  377. package/plugins/verify-otel/otlp.go +0 -200
  378. package/plugins/verify-otel/verify.go +0 -734
  379. package/plugins/verify-otel/verify_test.go +0 -460
  380. /package/{plugins/fetch-csr/options.schema.json → scripts/host-plugins/fetch-csr.options.json} +0 -0
  381. /package/{plugins/fetch-git/options.schema.json → scripts/host-plugins/fetch-git.options.json} +0 -0
package/README.md CHANGED
@@ -1,17 +1,44 @@
1
- # portolan
1
+ <p align="center">
2
+ <img src="./public/readme-header.webp" alt="A software architecture landscape mapped by Portolan" width="100%" />
3
+ </p>
2
4
 
3
- A browser for a software estate's architecture catalog: systems or bounded
4
- contexts, components, interfaces, events, flows, stores and ADRs, read out of
5
- the code and specs that already describe them, and rendered as a navigable
6
- site. DDD enriches the model when a repository really uses it; it is not a
7
- prerequisite.
5
+ <h1 align="center">Portolan</h1>
8
6
 
9
- The catalog and site are static end to end and require no backend. Optional
10
- branch comparison and source previews read immutable files from GitHub or
11
- GitLab at runtime; local development uses a localhost-only control plane.
7
+ <p align="center"><strong>Your architecture, read from the code.</strong></p>
12
8
 
13
- Live: <https://shortlink-org.github.io/portolan/> (the example estate in
14
- `examples/`).
9
+ <p align="center">
10
+ Turn code, contracts, schemas, traces and ADRs into a validated, navigable map of your software estate.
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="https://shortlink-org.github.io/portolan/landing">View the product tour</a>
15
+ ·
16
+ <a href="https://shortlink-org.github.io/portolan/?catalog=example">Explore the example catalog</a>
17
+ </p>
18
+
19
+ ## Make architecture visible — and keep it honest
20
+
21
+ Architecture documentation loses value when it becomes another system teams
22
+ must remember to maintain. Portolan starts with the evidence your repositories
23
+ already contain and turns it into one coherent, searchable view of the system.
24
+
25
+ See how bounded contexts, services, APIs, events, data stores, flows and
26
+ decisions fit together. Follow any relationship back to its source. Surface
27
+ missing contracts, ownership conflicts and architectural drift before they
28
+ become production surprises.
29
+
30
+ - **Understand the whole estate.** Move from the landscape to a single flow,
31
+ service, schema or decision without losing context.
32
+ - **Trust what you see.** Portolan merges and validates facts from code,
33
+ specifications and observed traces instead of relying on a second hand-built
34
+ inventory.
35
+ - **Publish anywhere.** The catalog and site are static end to end, require no
36
+ hosted backend and fit naturally into pull requests and CI.
37
+
38
+ DDD enriches the model when a repository uses it; it is never a prerequisite.
39
+ Optional branch comparison and source previews read immutable files from GitHub
40
+ or GitLab at runtime, while local development uses a localhost-only control
41
+ plane.
15
42
 
16
43
  ## What it does
17
44
 
@@ -49,45 +76,6 @@ Facts carry a status: `declared` (a fragment says so), `verified` (a recorded
49
76
  trace showed it happening), `unresolved` (nothing in the catalog answers the
50
77
  reference).
51
78
 
52
- ### Projects without DDD
53
-
54
- `extract-project` is the neutral baseline. It reads repository metadata and
55
- deployment/build manifests without executing project code, creates a `system`,
56
- `product`, `team` or `namespace` containing a component, and records its role
57
- (`application`, `worker`, `job`, `cli`, `library`, and so on) and technologies.
58
- It also lists the commands the runner files declare - make targets, npm
59
- scripts, just recipes, Taskfile and poe/pdm tasks, Maven and Gradle goals,
60
- cargo aliases and xtask subcommands - which `extract-commands` does on its
61
- own for a service a domain extractor describes.
62
- Contract, messaging and data extractors then add OpenAPI, AsyncAPI, GraphQL,
63
- proto, SQL, Redis, River, Watermill and outbound HTTP/SOAP facts to that same
64
- component.
65
- For Go services, the HTTP client extractor also joins common router
66
- registrations to handlers, interface calls, string-keyed factory branches and
67
- concrete providers. Constructor maps, fixed factories, capability assertions,
68
- composite/direct field assignment and setter injection are followed when the
69
- source proves one concrete target. The resulting flow starts at the inbound
70
- endpoint and fans out by the provider choices proved by source; when a
71
- provider's transport lives in another module, the flow stops at that
72
- implementation and says that the outbound transport could not be resolved.
73
- Endpoints without provider selection are composed too, including handlers
74
- passed through closures and local variables. Swagger `@Router` evidence can
75
- root a handler factory behind a custom registry, while calls reached from a
76
- `main → Run` assembly path become startup flows. These generated transport and
77
- async flows carry their trigger kind and static-confidence level; `AddFunc`,
78
- `AfterFunc`, and `Schedule` registrations become scheduled roots. A transport
79
- fragment with no proven root is marked `unproven` instead of looking like a
80
- complete scenario.
81
-
82
- The older JSON keys `contexts` and `services` remain the wire format, so old
83
- catalogs need no migration (portolan.0004). Optional `kind` fields say when those nodes should
84
- be read as a neutral group and component. When `kind` is absent, the historical
85
- `bounded-context` and `service` meanings apply.
86
-
87
- The setup wizard always offers the neutral extractor. A language-specific DDD
88
- extractor is selected only when its expected model structure is present; a
89
- `go.mod` or a directory merely named `internal/domain` is not sufficient.
90
-
91
79
  ## What the site shows
92
80
 
93
81
  - **Entity pages** — context, service, aggregate (entities, value objects,
@@ -160,9 +148,19 @@ npx portolan generate
160
148
  npx portolan dev
161
149
  ```
162
150
 
163
- `init` writes a minimal `portolan.json`, adds `.portolan/` to `.gitignore`,
164
- and, when the repository has a `package.json`, adds these scripts without
165
- replacing scripts that are already there:
151
+ `init` looks at the repository the way the site's Settings page does when a
152
+ project is added: it finds the directories that hold a build file, the domain
153
+ layouts, API specifications, schemas, migrations, ADRs and glossaries it can
154
+ read, and proposes a `portolan.json` with an extractor for each. In a terminal
155
+ it asks which directories are projects, what to read in each, and whether to
156
+ run `portolan generate` straight away; every question has the detected answer
157
+ as its default. With `--yes`, or without a terminal, it takes those defaults
158
+ and asks nothing. A plugin whose toolchain is not on `PATH` is pointed out
159
+ before anything is written.
160
+
161
+ `init` never overwrites an existing `portolan.json`. It adds `.portolan/` to
162
+ `.gitignore` and, when the repository has a `package.json`, adds these scripts
163
+ without replacing scripts that are already there:
166
164
 
167
165
  ```json
168
166
  {
@@ -181,89 +179,30 @@ deployable static site.
181
179
 
182
180
  | command | purpose |
183
181
  | --- | --- |
184
- | `portolan init` | create the first manifest without overwriting an existing one |
182
+ | `portolan init` | inspect the repository and write the first manifest; `--yes` takes every detected default |
185
183
  | `portolan dev` | run the local site and setup UI |
186
184
  | `portolan generate` | update fragments, documentation, and exports |
187
185
  | `portolan check` | fail when committed generated files are stale, without writing them |
188
186
  | `portolan build` | build the static site into `dist/` |
189
187
  | `portolan diff BASE` | describe the architecture change from a branch, tag, or commit |
190
- | `portolan doctor` | show which optional plugin toolchains are available |
191
-
192
- Node.js 24 is required. A process extractor also needs the toolchain of the
193
- language it reads: Go for the Go extractors, Python 3 for Python, Java 21 for
194
- Java, and Cargo for Rust. `portolan doctor` reports the local set. The Docker
195
- image contains all of them.
196
-
197
- ### Check pull requests with GitHub Actions
198
-
199
- Generated files are checked rather than silently rewritten in CI:
200
-
201
- ```yaml
202
- name: Architecture
203
- on: [pull_request]
204
-
205
- permissions:
206
- contents: read
207
-
208
- jobs:
209
- check:
210
- runs-on: ubuntu-latest
211
- steps:
212
- - uses: actions/checkout@v7
213
- with:
214
- fetch-depth: 0
215
- - uses: shortlink-org/portolan@0.1.0
216
- with:
217
- command: check
218
- version: 0.1.0
219
- ```
188
+ | `portolan doctor` | show which toolchains the manifest's plugins need and which are on `PATH` |
220
189
 
221
- Full history is required because fragments are stamped with the last commit
222
- that changed their input. A shallow clone would make that stamp unreliable.
223
-
224
- ### Publish the site to GitHub Pages
225
-
226
- ```yaml
227
- name: Architecture site
228
- on:
229
- push:
230
- branches: [main]
231
- workflow_dispatch:
232
-
233
- permissions:
234
- contents: read
235
- pages: write
236
- id-token: write
237
-
238
- jobs:
239
- build:
240
- runs-on: ubuntu-latest
241
- steps:
242
- - uses: actions/checkout@v7
243
- - uses: shortlink-org/portolan@0.1.0
244
- with:
245
- command: build
246
- version: 0.1.0
247
- output: dist
248
- base: /${{ github.event.repository.name }}/
249
- - uses: actions/configure-pages@v6
250
- - uses: actions/upload-pages-artifact@v5
251
- with:
252
- path: dist
253
-
254
- deploy:
255
- needs: build
256
- runs-on: ubuntu-latest
257
- environment:
258
- name: github-pages
259
- url: ${{ steps.deployment.outputs.page_url }}
260
- steps:
261
- - id: deployment
262
- uses: actions/deploy-pages@v5
263
- ```
190
+ Node.js 24 is required, and for a repository the built-in extractors can
191
+ read on their own it is the only requirement: every Go plugin runs as one
192
+ wasm module over the workspace (`adr/0006`), the fetchers run inside the
193
+ host (`adr/0008`), and the package ships no Go at all. A Go, TypeScript,
194
+ OpenAPI, proto, SQL or GraphQL tree is read, and another repository or a
195
+ schema registry is vendored, with Node and git alone. An extractor that runs
196
+ in its own runtime still needs it: Python 3 for Django and Celery, Java 21
197
+ for Java, Cargo for Rust. `portolan doctor` reports what the manifest asks
198
+ for against what is on `PATH`. The Docker image contains all of them.
199
+
200
+ ### Add delivery automation
264
201
 
265
- The action deliberately separates building from deployment, so the Pages
266
- permissions are held only by the deploy job.
202
+ While `portolan dev` is running, open **Settings Delivery presets**. Portolan
203
+ detects GitHub or GitLab from the repository's `origin`, previews the exact CI
204
+ changes, and installs architecture checks and static catalog publishing in one
205
+ step. Existing unmanaged workflow files are never overwritten.
267
206
 
268
207
  ### Run without installing Node or language toolchains
269
208
 
@@ -276,7 +215,7 @@ docker run --rm \
276
215
  -e HOME=/tmp \
277
216
  -v "$PWD:/workspace" \
278
217
  -w /workspace \
279
- ghcr.io/shortlink-org/portolan:0.1.0 generate
218
+ ghcr.io/shortlink-org/portolan:0.2.0 generate
280
219
  ```
281
220
 
282
221
  Use immutable versions in CI. `latest` is intended for trying the CLI, not for
@@ -310,78 +249,3 @@ Generated output is committed, so a change to it shows up in a diff. CI builds
310
249
  the site (`npm run build`); the `--check` variants and the test suites are run
311
250
  locally before a change lands, since they need the Go, Java, Rust and Python
312
251
  toolchains the plugins are written in.
313
-
314
- ### Release Portolan
315
-
316
- Give the repository an `NPM_TOKEN` Actions secret that can publish the
317
- `@shortlink-org/portolan` package. Push a tag matching the version in
318
- `package.json`, for example `0.1.0`. The release workflow verifies the package,
319
- publishes it to npm with provenance, builds multi-platform container images at
320
- `ghcr.io/shortlink-org/portolan`, attests the image, and creates or updates the
321
- GitHub release notes. The tag itself is also the immutable version of the
322
- composite action used by consumer repositories.
323
-
324
- ### In a pull request
325
-
326
- `gen:check` says the documentation follows from the catalog; it does not say
327
- what the change does. `.github/workflows/architecture-diff.yml` puts that on
328
- the pull request itself, without ever failing it:
329
-
330
- - one sticky comment with the events, endpoints, transitions and owners that
331
- are not what they were, breaking first, ending in a link into the published
332
- site's Changes page for the same pair of branches;
333
- - the same list as SARIF in the checks tab, breaking as errors, each result
334
- anchored at the fragment that declares the id, so code scanning shows it;
335
- - the report again in the job summary, for whoever reads the run.
336
-
337
- The comment is `scripts/forge-comment.mjs` over the markdown `npm run diff`
338
- writes. It finds its own earlier comment by a marker and updates it in place.
339
- `GITHUB_TOKEN` with `pull-requests: write` is enough on GitHub; a pull request
340
- from a fork has a read-only token, and the step says so and stays green.
341
-
342
- The Changes page itself compares the built catalog with any branch or tag the
343
- forge lists, so the link in the comment works for a release just as well.
344
-
345
- On GitLab the job token cannot write notes, so the job wants a project access
346
- token with the `api` scope and the Reporter role as `PORTOLAN_TOKEN`:
347
-
348
- ```yaml
349
- architecture-diff:
350
- image: node:24
351
- rules: [{ if: $CI_PIPELINE_SOURCE == "merge_request_event" }]
352
- script:
353
- - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
354
- - npm ci
355
- - node scripts/diff.mjs "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --site "$CI_PAGES_URL" --head "$CI_COMMIT_REF_NAME" --output .portolan/diff.md
356
- - node scripts/forge-comment.mjs .portolan/diff.md
357
- ```
358
-
359
- ### In a release
360
-
361
- `.github/workflows/release-notes.yml` runs the same diff when a `v*` tag is
362
- pushed, against the tag before it, and `scripts/forge-release.mjs` writes it
363
- into the release as one section between two markers: the rest of the notes,
364
- hand-written or generated by the forge, stays as it was, and a rerun replaces
365
- the section rather than repeating it. A tag with no release yet gets one,
366
- named after the tag. The first tag of a repository has nothing to compare
367
- against, and the job says so and stops. The same job on GitLab:
368
-
369
- ```yaml
370
- architecture-release:
371
- image: node:24
372
- rules: [{ if: $CI_COMMIT_TAG }]
373
- script:
374
- - git fetch --tags origin
375
- - npm ci
376
- - previous=$(git describe --tags --abbrev=0 "$CI_COMMIT_TAG^")
377
- - node scripts/diff.mjs "$previous" --site "$CI_PAGES_URL" --head "$CI_COMMIT_TAG" --output .portolan/release.md
378
- - node scripts/forge-release.mjs .portolan/release.md
379
- ```
380
-
381
- Three build-time variables shape the chat:
382
-
383
- | variable | effect |
384
- |---|---|
385
- | `VITE_CHAT=off` | no chat at all: no button, no settings section, and its chunk is not built |
386
- | `VITE_CHAT_PROXY_URL` | the worker that answers with a key of its own (see `proxy/README.md`); unset, the chat waits for the reader's own model |
387
- | (a switch in Settings) | the reader turns the chat on or off in their browser; on by default when a proxy answers |
package/cli/init.mjs ADDED
@@ -0,0 +1,285 @@
1
+ // `portolan init`: look at the repository, propose a manifest, write it.
2
+ //
3
+ // Detection is the same code the site's Settings page uses to add a project
4
+ // (scripts/local-api.mjs), so the CLI and the UI agree on what a repository
5
+ // contains. The prompts are the only thing this file adds; with `--yes`, or
6
+ // without a terminal, every question takes its default.
7
+
8
+ import { spawnSync } from "node:child_process";
9
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { basename, posix, resolve } from "node:path";
11
+
12
+ import * as p from "@clack/prompts";
13
+
14
+ import { INSTALL_ROOT, builtinDefinition } from "../scripts/builtin-plugins.mjs";
15
+ import { discoverProject, manifestWithProject, planProject, writeManifest } from "../scripts/local-api.mjs";
16
+
17
+ const TOOLCHAINS = { go: "Go", cargo: "Cargo", java: "Java", python3: "Python 3" };
18
+ const VERSION_FLAGS = { go: ["version"], cargo: ["--version"], java: ["-version"], python3: ["--version"] };
19
+
20
+ export const SCRIPTS = {
21
+ "architecture": "portolan dev",
22
+ "architecture:gen": "portolan generate",
23
+ "architecture:check": "portolan check",
24
+ "architecture:build": "portolan build",
25
+ };
26
+
27
+ export class InitError extends Error {}
28
+
29
+ /**
30
+ * Create portolan.json for `workspace`.
31
+ *
32
+ * `ask` answers the questions init has; `defaultAnswers` takes every default
33
+ * and `promptAnswers` asks a terminal. Returns what was done so the caller can
34
+ * continue with `portolan generate` when the user asked for it.
35
+ */
36
+ export async function init(workspace, { version, ask = defaultAnswers, log = console.log } = {}) {
37
+ const manifestPath = resolve(workspace, "portolan.json");
38
+ if (existsSync(manifestPath)) throw new InitError("portolan.json already exists; init did not change it");
39
+
40
+ const projectPackage = readJson(resolve(workspace, "package.json"));
41
+ const repository = slug(String(projectPackage?.name ?? "").replace(/^@[^/]+\//, "")) || slug(basename(workspace)) || "project";
42
+
43
+ const scan = ask.scan?.() ?? null;
44
+ const discovery = discoverProject(workspace, ".");
45
+ scan?.done(discovery.filesScanned, discovery.truncated);
46
+
47
+ const roots = await ask.roots(rootChoices(discovery.components));
48
+ const single = roots.length === 1 && roots[0] === ".";
49
+
50
+ const projects = [];
51
+ const usedIds = new Set();
52
+ for (const root of roots) {
53
+ const found = root === "." ? discovery : discoverProject(workspace, root);
54
+ const id = single ? repository : uniqueId(root, usedIds);
55
+ const defaults = { id, name: titleOf(id), group: single ? id : repository, component: id };
56
+ const identity = single && found.detections.length ? await ask.identity(defaults) : defaults;
57
+ usedIds.add(identity.id);
58
+
59
+ const plugins = found.detections.length
60
+ ? await ask.plugins(root, found.detections.map((detection) => ({ ...detection, requirement: toolchainFor(detection.plugin) })))
61
+ : [];
62
+ projects.push({ root, ...identity, plugins });
63
+ }
64
+
65
+ const missing = missingToolchains(projects.flatMap((project) => project.plugins));
66
+ for (const [label, plugins] of missing) ask.warn(`${plugins.join(", ")} need${plugins.length === 1 ? "s" : ""} ${label}, which is not on PATH; portolan doctor lists the toolchains`);
67
+
68
+ const title = single ? projects[0].name : titleOf(repository);
69
+ const manifest = buildManifest(workspace, projects, { version, title });
70
+
71
+ const rows = projects.map((project) => [project.id, project.root, project.plugins.length ? project.plugins.join(", ") : "project (nothing detected)"]);
72
+ const widths = [0, 1].map((column) => Math.max(...rows.map((row) => row[column].length)));
73
+ const summary = rows.map((row) => `${row[0].padEnd(widths[0])} ${row[1].padEnd(widths[1])} ${row[2]}`);
74
+ if (!(await ask.write(summary))) throw new InitError("cancelled; nothing was written");
75
+ writeManifest(manifestPath, manifest);
76
+ log("created portolan.json");
77
+
78
+ const scripts = projectPackage ? await ask.scripts(Object.keys(SCRIPTS).filter((name) => !(name in (projectPackage.scripts ?? {})))) : false;
79
+ if (scripts) {
80
+ const merged = { ...(projectPackage.scripts ?? {}) };
81
+ for (const [name, command] of Object.entries(SCRIPTS)) if (!(name in merged)) merged[name] = command;
82
+ writeFileSync(resolve(workspace, "package.json"), `${JSON.stringify({ ...projectPackage, scripts: merged }, null, 2)}\n`);
83
+ log("added architecture scripts to package.json");
84
+ }
85
+
86
+ const ignorePath = resolve(workspace, ".gitignore");
87
+ const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
88
+ if (!ignore.split(/\r?\n/).includes(".portolan/")) {
89
+ writeFileSync(ignorePath, `${ignore}${ignore && !ignore.endsWith("\n") ? "\n" : ""}\n# Portolan local build state\n.portolan/\n`);
90
+ }
91
+
92
+ const generate = await ask.generate();
93
+ ask.finish(generate);
94
+ return { manifest, projects, generate };
95
+ }
96
+
97
+ /** Every question answered with its default: the `--yes` and CI path. */
98
+ export const defaultAnswers = {
99
+ roots: (choices) => choices.filter((choice) => choice.selected).map((choice) => choice.path),
100
+ identity: (defaults) => defaults,
101
+ plugins: (root, detections) => detections.filter((detection) => detection.selected && detection.confidence === "high").map((detection) => detection.plugin),
102
+ warn: (message) => console.warn(`portolan: ${message}`),
103
+ write: () => true,
104
+ scripts: (names) => names.length > 0,
105
+ generate: () => false,
106
+ finish: (generate) => { if (!generate) console.log("next: portolan generate && portolan dev"); },
107
+ };
108
+
109
+ /** The same questions, asked in a terminal with @clack/prompts. */
110
+ export function promptAnswers(version) {
111
+ p.intro(`portolan ${version}`);
112
+ let spinner;
113
+ return {
114
+ scan() {
115
+ spinner = p.spinner();
116
+ spinner.start("Scanning the repository");
117
+ return {
118
+ done(files, truncated) {
119
+ spinner.stop(`Scanned ${files} files${truncated ? " (stopped early; a large repository)" : ""}`);
120
+ },
121
+ };
122
+ },
123
+ async roots(choices) {
124
+ if (choices.length <= 1) return defaultAnswers.roots(choices);
125
+ return answer(await p.multiselect({
126
+ message: "Which directories are projects?",
127
+ options: choices.map((choice) => ({ value: choice.path, label: choice.path, hint: [choice.name, choice.technologies.join(", ")].filter(Boolean).join(" · ") })),
128
+ initialValues: defaultAnswers.roots(choices),
129
+ required: true,
130
+ }));
131
+ },
132
+ async identity(defaults) {
133
+ const id = answer(await p.text({
134
+ message: "Project id",
135
+ initialValue: defaults.id,
136
+ validate: (value) => (slug(value) ? undefined : "Use letters, digits and dashes."),
137
+ }));
138
+ const clean = slug(id);
139
+ return clean === defaults.id ? defaults : { ...defaults, id: clean, name: titleOf(clean), group: clean, component: clean };
140
+ },
141
+ async plugins(root, detections) {
142
+ const chosen = answer(await p.multiselect({
143
+ message: root === "." ? "What should Portolan read?" : `What should Portolan read in ${root}?`,
144
+ options: detections.map((detection) => ({
145
+ value: detection.plugin,
146
+ label: detection.plugin,
147
+ hint: [detection.evidence, detection.requirement?.missing ? `needs ${detection.requirement.label}, not on PATH` : ""].filter(Boolean).join(" · "),
148
+ })),
149
+ initialValues: defaultAnswers.plugins(root, detections),
150
+ required: true,
151
+ }));
152
+ return chosen;
153
+ },
154
+ warn: (message) => p.log.warn(message),
155
+ async write(summary) {
156
+ p.note(summary.join("\n"), "portolan.json");
157
+ return answer(await p.confirm({ message: "Write portolan.json?", initialValue: true }));
158
+ },
159
+ async scripts(names) {
160
+ if (!names.length) return false;
161
+ return answer(await p.confirm({ message: `Add ${names.join(", ")} to package.json scripts?`, initialValue: true }));
162
+ },
163
+ async generate() {
164
+ return answer(await p.confirm({ message: "Run portolan generate now?", initialValue: true }));
165
+ },
166
+ finish(generate) {
167
+ p.outro(generate ? "Generating; then: portolan dev" : "Next: portolan generate && portolan dev");
168
+ },
169
+ };
170
+ }
171
+
172
+ export function isInteractive(env = process.env) {
173
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY) && !env.CI;
174
+ }
175
+
176
+ export function commandWorks(command, args) {
177
+ const result = spawnSync(command, args, { stdio: "ignore" });
178
+ return !result.error && result.status === 0;
179
+ }
180
+
181
+ function answer(value) {
182
+ if (p.isCancel(value)) {
183
+ p.cancel("init cancelled; nothing was written");
184
+ process.exit(0);
185
+ }
186
+ return value;
187
+ }
188
+
189
+ /**
190
+ * Directories that could be projects, with the default choice made: the
191
+ * repository root when it has a build file of its own, otherwise every nested
192
+ * directory that has one.
193
+ */
194
+ function rootChoices(components) {
195
+ const rootHasMarkers = components.some((candidate) => candidate.path === ".");
196
+ const nested = components.filter((candidate) => candidate.path !== ".");
197
+ const choices = [
198
+ { path: ".", name: "repository root", markers: [], technologies: [], ...components.find((candidate) => candidate.path === ".") },
199
+ ...nested,
200
+ ];
201
+ return choices.map((choice) => ({ ...choice, selected: choice.path === "." ? rootHasMarkers || nested.length === 0 : !rootHasMarkers }));
202
+ }
203
+
204
+ function buildManifest(workspace, projects, { version, title }) {
205
+ let manifest = {
206
+ $schema: `https://raw.githubusercontent.com/shortlink-org/portolan/${version}/schema/portolan.schema.json`,
207
+ sources: [],
208
+ projects: [],
209
+ extract: [],
210
+ };
211
+ for (const project of projects) {
212
+ if (project.plugins.length) {
213
+ const plan = planProject(workspace, manifest, {
214
+ root: project.root,
215
+ id: project.id,
216
+ name: project.name,
217
+ group: project.group,
218
+ component: project.component,
219
+ plugins: project.plugins,
220
+ });
221
+ manifest = manifestWithProject(manifest, plan);
222
+ continue;
223
+ }
224
+ // Nothing to read yet; the project extractor still gives the site one node.
225
+ const out = posix.join(project.root, "portolan");
226
+ manifest.projects.push({ id: project.id, name: project.name, root: project.root, group: project.group, component: project.component });
227
+ manifest.sources.push(`${out}/*.json`);
228
+ manifest.extract.push({
229
+ plugin: "project",
230
+ in: project.root,
231
+ out,
232
+ options: { group: project.group, groupName: titleOf(project.group), component: project.component, componentName: project.name, out: "project.json" },
233
+ });
234
+ }
235
+ manifest.sources = [...new Set(manifest.sources)];
236
+ manifest.generate = [
237
+ { plugin: "markdown", out: "docs", options: { title } },
238
+ { plugin: "mermaid", out: "exports/mermaid", options: { title: `${title} flows` } },
239
+ ];
240
+ return manifest;
241
+ }
242
+
243
+ const toolchainChecks = new Map();
244
+
245
+ export function toolchainFor(plugin, declared = null) {
246
+ const command = declared?.process?.command ?? builtinDefinition(plugin)?.process?.command;
247
+ const label = TOOLCHAINS[command];
248
+ if (!label) return null;
249
+ if (!toolchainChecks.has(command)) {
250
+ // The Rust extractor ships as a binary in the Docker image, and
251
+ // run-builtin uses it before it would ever ask for Cargo; doctor and init
252
+ // agree, or the image would report a toolchain it does not need.
253
+ const prebuilt = command === "cargo" && !declared && existsSync(resolve(INSTALL_ROOT, "plugins/extract-rust/target/release/portolan-extract-rust"));
254
+ toolchainChecks.set(command, prebuilt || commandWorks(command, VERSION_FLAGS[command]));
255
+ }
256
+ return { command, label, missing: !toolchainChecks.get(command) };
257
+ }
258
+
259
+ function missingToolchains(plugins) {
260
+ const missing = new Map();
261
+ for (const plugin of new Set(plugins)) {
262
+ const need = toolchainFor(plugin);
263
+ if (!need?.missing) continue;
264
+ missing.set(need.label, [...(missing.get(need.label) ?? []), plugin]);
265
+ }
266
+ return missing;
267
+ }
268
+
269
+ function uniqueId(root, used) {
270
+ const short = slug(posix.basename(root));
271
+ if (short && !used.has(short)) return short;
272
+ return slug(root) || `project-${used.size + 1}`;
273
+ }
274
+
275
+ function readJson(path) {
276
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
277
+ }
278
+
279
+ export function slug(value) {
280
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
281
+ }
282
+
283
+ export function titleOf(value) {
284
+ return value.split("-").filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
285
+ }