@shortlink-org/portolan 0.1.0 → 0.2.0

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 +22 -4
  36. package/scripts/local-api.test.mjs +84 -2
  37. package/scripts/manifest.mjs +3 -2
  38. package/scripts/package-smoke.mjs +6 -2
  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 +12 -8
  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
@@ -1,642 +0,0 @@
1
- package main
2
-
3
- import (
4
- "fmt"
5
- "path"
6
- "regexp"
7
- "strconv"
8
- "strings"
9
- "time"
10
-
11
- "github.com/shortlink-org/portolan/catalog"
12
- )
13
-
14
- // The format, in one place.
15
- //
16
- // # auth.0003 — Session expiry publishes no event
17
- //
18
- // - **Status:** accepted
19
- // - **Date:** 2026-08-22
20
- // - **Scope:** auth.auth
21
- // - **Superseded by:** auth.0007 optional
22
- // - **Supersedes:** auth.0001, auth.0002 optional
23
- // - **Relates:** auth.auth.session.SessionEnded, shop.cart, checkout
24
- // - **Note:** prose the catalog has no other field for
25
- //
26
- // ## Context and Problem Statement
27
- // …
28
- //
29
- // The title carries the id and, after an em dash, the title itself. The id's
30
- // prefix is whatever the record is about - a service (`auth`), a context
31
- // (`payments`) or the organisation (`org`) - and the four digits after it are
32
- // the record's number, which the file's own name repeats.
33
- //
34
- // The bullets are everything the markdown knows that the prose does not say
35
- // in a form anything can read. Status, Date and Scope are required; the rest
36
- // are written when there is something to write. `Relates` names events,
37
- // services and flows in one list and they are told apart by their shape,
38
- // because a record's author should not have to remember which of three lists
39
- // a name belongs in.
40
- //
41
- // Everything from the first `##` onward is the record. It is frozen history:
42
- // it goes into the catalog exactly as written and comes out onto the page the
43
- // same way, and nothing in it is ever regenerated from the model as it stands
44
- // now.
45
- //
46
- // The other format read here is the one adr-tools writes, because a tree of
47
- // records kept that way for years should not have to be retyped to be read:
48
- //
49
- // # 2. Integration with external suppliers
50
- //
51
- // Date: 2024-09-04
52
- //
53
- // ## Status
54
- //
55
- // Superseded by [5. Use schemas](0005-use-schemas.md)
56
- //
57
- // ## Context
58
- // …
59
- //
60
- // The title carries the number and the title; the id's prefix, which the
61
- // format has no place for, comes from the step's scope option, as does the
62
- // scope itself. The date is a line of its own above the record, and the status
63
- // is the first section of the record, where adr-tools writes it - a word, or
64
- // "Superseded by" and a link to the record that replaced this one, and
65
- // "Supersedes" and a link the other way. The record is the same as above:
66
- // everything from the first `##`, the status section included.
67
-
68
- var (
69
- titleLine = regexp.MustCompile(`^#\s+(\S+)\s+—\s+(.+?)\s*$`)
70
- toolsTitle = regexp.MustCompile(`^#\s+(\d+)\.\s+(.+?)\s*$`)
71
- plainTitle = regexp.MustCompile(`^#\s+(.+?)\s*$`)
72
- toolsDate = regexp.MustCompile(`^Date:\s*(.+?)\s*$`)
73
- toolsLink = regexp.MustCompile(`\[\s*(\d+)\.[^\]]*\]\([^)]*\)`)
74
- toolsBare = regexp.MustCompile(`(?i)^(?:adr[-\s]?)?0*(\d+)$`)
75
- statusHead = regexp.MustCompile(`^##\s+Status\s*$`)
76
- sectionAny = regexp.MustCompile(`^#{1,2}\s`)
77
- bulletLine = regexp.MustCompile(`^-\s+\*\*([^*:]+):\*\*\s*(.*?)\s*$`)
78
- bodyStart = regexp.MustCompile(`^##\s`)
79
- fileName = regexp.MustCompile(`^(\d{4})-([a-z0-9]+(?:-[a-z0-9]+)*)$`)
80
- adrID = regexp.MustCompile(`^([a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*)\.(\d+)$`)
81
- scopeValue = regexp.MustCompile(`^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)?$`)
82
- flowSlug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
83
- eventID = regexp.MustCompile(`^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*\.[A-Za-z][A-Za-z0-9]*$`)
84
- serviceID = regexp.MustCompile(`^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$`)
85
- )
86
-
87
- var statuses = map[string]catalog.AdrStatus{
88
- "proposed": catalog.AdrProposed,
89
- "accepted": catalog.AdrAccepted,
90
- "superseded": catalog.AdrSuperseded,
91
- "deprecated": catalog.AdrDeprecated,
92
- "rejected": catalog.AdrRejected,
93
- }
94
-
95
- // defaults are what the manifest says about a tree of records, for the
96
- // records that do not say it themselves.
97
- type defaults struct {
98
- // Scope is what a record is about when it has no Scope of its own: "org",
99
- // a context, or "<context>.<service>". It also lends an adr-tools record
100
- // the prefix of its id, which is the last segment: the service, the
101
- // context, or "org".
102
- Scope string
103
- }
104
-
105
- type parser struct {
106
- file string
107
- lines []string
108
- errs []string
109
- d defaults
110
-
111
- // tools is set when the title is the one adr-tools writes, and picks the
112
- // way the rest of the file is read.
113
- tools bool
114
-
115
- adr catalog.Adr
116
- }
117
-
118
- // parseAdr reads one record. Every mistake it can find is collected rather
119
- // than returned at the first one, because a file with two typos in its header
120
- // should be fixed once.
121
- func parseAdr(file, src string, d defaults) (catalog.Adr, []string) {
122
- p := &parser{
123
- file: file,
124
- lines: strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n"),
125
- d: d,
126
- }
127
- p.read()
128
- if len(p.errs) > 0 {
129
- return catalog.Adr{}, p.errs
130
- }
131
-
132
- return p.adr, nil
133
- }
134
-
135
- func (p *parser) fail(line int, msg string) {
136
- p.errs = append(p.errs, p.file+":"+strconv.Itoa(line+1)+": "+msg)
137
- }
138
-
139
- func (p *parser) read() {
140
- p.adr.Source = p.file
141
- p.adr.Relates = catalog.AdrRelates{}
142
-
143
- head := p.title()
144
- if head < 0 {
145
- return
146
- }
147
- p.checkFileName()
148
-
149
- body := -1
150
- if p.tools {
151
- body = p.toolsMeta(head + 1)
152
- } else {
153
- body = p.meta(head + 1)
154
- }
155
- if body < 0 {
156
- return
157
- }
158
- p.adr.Body = strings.TrimSpace(strings.Join(p.lines[body:], "\n")) + "\n"
159
- if p.tools {
160
- p.toolsStatus(body)
161
- p.checkSupersession(head)
162
- }
163
- }
164
-
165
- // title reads the one line the record has to open with and answers with its
166
- // index, or -1 when there is nothing to go on.
167
- func (p *parser) title() int {
168
- for i, line := range p.lines {
169
- if strings.TrimSpace(line) == "" {
170
- continue
171
- }
172
-
173
- match := titleLine.FindStringSubmatch(line)
174
- if match == nil {
175
- if tools := toolsTitle.FindStringSubmatch(line); tools != nil {
176
- return p.toolsTitleLine(i, tools)
177
- }
178
- // A title with no number at all is numbered by its file, unless
179
- // it opens with something shaped like an id: that is a MADR
180
- // title with the wrong dash, and reading it as a plain one would
181
- // turn a typo into a record under another name.
182
- if plain := plainTitle.FindStringSubmatch(line); plain != nil && !adrID.MatchString(strings.Fields(plain[1])[0]) {
183
- return p.plainTitleLine(i, plain[1])
184
- }
185
- p.fail(i, `a record opens with "# <id> — <title>", an em dash between the two, or with "# <n>. <title>" as adr-tools writes it`)
186
-
187
- return -1
188
- }
189
-
190
- id := adrID.FindStringSubmatch(match[1])
191
- if id == nil {
192
- p.fail(i, "the id "+strconv.Quote(match[1])+` is not a prefix and a number, as in "auth.0003"`)
193
-
194
- return -1
195
- }
196
- number, err := strconv.Atoi(id[2])
197
- if err != nil || fmt.Sprintf("%04d", number) != id[2] {
198
- // The app fails the whole catalog on an id that does not end with
199
- // its own number, so the digits are the number and are written
200
- // the one way that round-trips.
201
- p.fail(i, "the number in "+strconv.Quote(match[1])+" is not four padded digits")
202
-
203
- return -1
204
- }
205
-
206
- p.adr.ID = match[1]
207
- p.adr.Number = number
208
- p.adr.Title = strings.TrimSpace(match[2])
209
-
210
- return i
211
- }
212
-
213
- p.fail(0, "the file is empty")
214
-
215
- return -1
216
- }
217
-
218
- // toolsTitleLine reads the title adr-tools writes: the record's number, a
219
- // full stop, the title. The id is built from the number and the prefix the
220
- // scope lends, because the format keeps no id of its own.
221
- func (p *parser) toolsTitleLine(i int, match []string) int {
222
- number, err := strconv.Atoi(match[1])
223
- if err != nil || number <= 0 {
224
- p.fail(i, "the number "+strconv.Quote(match[1])+" is not a record's number")
225
-
226
- return -1
227
- }
228
-
229
- p.tools = true
230
- p.adr.ID = p.toolsID(number)
231
- p.adr.Number = number
232
- p.adr.Title = strings.TrimSpace(match[2])
233
-
234
- return i
235
- }
236
-
237
- // plainTitleLine reads a title that carries no number, which adr-tools trees
238
- // collect when a record is written by hand: the file's own name numbers it,
239
- // and the heading is the title.
240
- func (p *parser) plainTitleLine(i int, title string) int {
241
- match := fileName.FindStringSubmatch(strings.TrimSuffix(path.Base(p.file), ".md"))
242
- if match == nil {
243
- p.fail(i, `the title carries no number and the file is not named "NNNN-kebab-slug.md", so nothing numbers the record`)
244
-
245
- return -1
246
- }
247
- number, _ := strconv.Atoi(match[1])
248
- if number <= 0 {
249
- p.fail(i, "the file is numbered "+match[1]+", which is not a record's number")
250
-
251
- return -1
252
- }
253
-
254
- p.tools = true
255
- p.adr.ID = p.toolsID(number)
256
- p.adr.Number = number
257
- p.adr.Title = strings.TrimSpace(title)
258
-
259
- return i
260
- }
261
-
262
- // toolsID is the id an adr-tools record gets: the prefix the scope lends and
263
- // the number, padded the one way that round-trips.
264
- func (p *parser) toolsID(number int) string {
265
- return p.prefix() + "." + fmt.Sprintf("%04d", number)
266
- }
267
-
268
- // prefix is what an id opens with, taken from the scope the way the MADR
269
- // records of the estate do it: the service for a service, the context for a
270
- // context, and "org" for the organisation.
271
- func (p *parser) prefix() string {
272
- scope := strings.TrimSpace(p.d.Scope)
273
- if scope == "" || scope == "org" {
274
- return "org"
275
- }
276
- if _, service, ok := strings.Cut(scope, "."); ok {
277
- return service
278
- }
279
-
280
- return scope
281
- }
282
-
283
- // toolsMeta reads what adr-tools keeps above the record - the date, on a line
284
- // of its own - and answers with the index the record starts at, or -1 when
285
- // there is none.
286
- func (p *parser) toolsMeta(from int) int {
287
- for i := from; i < len(p.lines); i++ {
288
- line := p.lines[i]
289
- switch {
290
- case bodyStart.MatchString(line):
291
- if p.adr.Date == "" {
292
- p.fail(from-1, `the record says no "Date:"`)
293
- }
294
- p.scope(from-1, p.d.Scope)
295
-
296
- return i
297
-
298
- case strings.TrimSpace(line) == "":
299
-
300
- case toolsDate.MatchString(line):
301
- value := toolsDate.FindStringSubmatch(line)[1]
302
- if _, err := time.Parse("2006-01-02", value); err != nil {
303
- p.fail(i, strconv.Quote(value)+" is not a date, as in 2026-08-22")
304
-
305
- continue
306
- }
307
- p.adr.Date = value
308
-
309
- default:
310
- p.fail(i, "only \"Date:\" belongs between the title and the first `##`")
311
-
312
- return -1
313
- }
314
- }
315
-
316
- p.fail(len(p.lines)-1, "the record has no body: nothing here is under a `##`")
317
-
318
- return -1
319
- }
320
-
321
- // toolsStatus reads the status from the record's own Status section, which is
322
- // where adr-tools keeps it. Each non-empty line there is one statement: a
323
- // status word, or "Superseded by" or "Supersedes" and the record it means. A
324
- // later statement wins over an earlier one, because a record that was accepted
325
- // and then superseded has both written down, in that order.
326
- func (p *parser) toolsStatus(body int) {
327
- head := -1
328
- for i := body; i < len(p.lines); i++ {
329
- if statusHead.MatchString(p.lines[i]) {
330
- head = i
331
-
332
- break
333
- }
334
- }
335
- if head < 0 {
336
- p.fail(body, "the record has no `## Status` section")
337
-
338
- return
339
- }
340
-
341
- statements := 0
342
- for i := head + 1; i < len(p.lines) && !sectionAny.MatchString(p.lines[i]); i++ {
343
- statement := strings.TrimSpace(p.lines[i])
344
- if statement == "" {
345
- continue
346
- }
347
- statements++
348
- p.toolsStatement(i, statement)
349
- }
350
- if statements == 0 {
351
- p.fail(head, "the Status section says nothing")
352
- }
353
- if p.adr.Status == "" && len(p.errs) == 0 {
354
- p.fail(head, "the Status section names no status: proposed, accepted, superseded, deprecated or rejected")
355
- }
356
- }
357
-
358
- func (p *parser) toolsStatement(at int, statement string) {
359
- lower := strings.ToLower(strings.TrimRight(statement, ". "))
360
- if status, ok := statuses[lower]; ok {
361
- p.adr.Status = status
362
-
363
- return
364
- }
365
-
366
- switch {
367
- case strings.HasPrefix(lower, "superseded by"):
368
- ids := p.toolsRecords(statement[len("superseded by"):])
369
- if len(ids) != 1 {
370
- p.fail(at, "the record is superseded and says by which one record")
371
-
372
- return
373
- }
374
- p.adr.Status = catalog.AdrSuperseded
375
- p.adr.SupersededBy = ids[0]
376
-
377
- case strings.HasPrefix(lower, "supersedes"):
378
- ids := p.toolsRecords(statement[len("supersedes"):])
379
- if len(ids) == 0 {
380
- p.fail(at, "the record supersedes something and says which record")
381
-
382
- return
383
- }
384
- p.adr.Supersedes = append(p.adr.Supersedes, ids...)
385
-
386
- case strings.HasPrefix(lower, "amends"), strings.HasPrefix(lower, "amended by"):
387
- // An amendment is a fact the catalog has no field for. It stays in the
388
- // body, which is on the page as written.
389
-
390
- default:
391
- p.fail(at, strconv.Quote(statement)+` is not a status: proposed, accepted, superseded, deprecated or rejected, or "Superseded by" a record`)
392
- }
393
- }
394
-
395
- // toolsRecords finds the records a statement names, as the links adr-tools
396
- // writes - "[3. Use DTO](0003-use-dto.md)" - or as bare numbers.
397
- func (p *parser) toolsRecords(rest string) []string {
398
- var ids []string
399
- for _, match := range toolsLink.FindAllStringSubmatch(rest, -1) {
400
- number, err := strconv.Atoi(match[1])
401
- if err != nil {
402
- continue
403
- }
404
- ids = append(ids, p.toolsID(number))
405
- }
406
- if len(ids) > 0 {
407
- return ids
408
- }
409
- for _, word := range strings.Fields(strings.NewReplacer(",", " ", "and", " ").Replace(rest)) {
410
- if match := toolsBare.FindStringSubmatch(word); match != nil {
411
- number, _ := strconv.Atoi(match[1])
412
- ids = append(ids, p.toolsID(number))
413
- }
414
- }
415
-
416
- return ids
417
- }
418
-
419
- // checkFileName ties the record to the file it is in. The slug is built from
420
- // both - the id says which record this is, the file's name says what it was
421
- // about - so a file renamed away from its record would silently change the
422
- // URL of a decision somebody linked to.
423
- func (p *parser) checkFileName() {
424
- base := strings.TrimSuffix(path.Base(p.file), ".md")
425
-
426
- match := fileName.FindStringSubmatch(base)
427
- if match == nil {
428
- p.fail(0, "the file is named "+strconv.Quote(base+".md")+`, not "NNNN-kebab-slug.md"`)
429
-
430
- return
431
- }
432
- if number, _ := strconv.Atoi(match[1]); number != p.adr.Number {
433
- p.fail(0, "the file is numbered "+match[1]+" and the record inside it is "+p.adr.ID)
434
-
435
- return
436
- }
437
-
438
- p.adr.Slug = strings.ReplaceAll(p.adr.ID, ".", "-") + "-" + match[2]
439
- }
440
-
441
- // meta reads the bullets between the title and the record, and answers with
442
- // the index the record starts at, or -1 when there is none.
443
- func (p *parser) meta(from int) int {
444
- seen := map[string]bool{}
445
- key, value, at := "", "", 0
446
- flush := func() {
447
- if key != "" {
448
- p.bullet(at, key, value)
449
- }
450
- key, value = "", ""
451
- }
452
-
453
- for i := from; i < len(p.lines); i++ {
454
- line := p.lines[i]
455
- switch {
456
- case bodyStart.MatchString(line):
457
- flush()
458
- p.require(from-1, seen)
459
-
460
- return i
461
-
462
- case strings.TrimSpace(line) == "":
463
- flush()
464
-
465
- case bulletLine.MatchString(line):
466
- flush()
467
- match := bulletLine.FindStringSubmatch(line)
468
- key, value, at = strings.TrimSpace(match[1]), match[2], i
469
- if seen[key] {
470
- p.fail(i, strconv.Quote(key)+" is written twice")
471
- }
472
- seen[key] = true
473
-
474
- case key != "" && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")):
475
- // A bullet wraps onto the next line, indented under itself. The
476
- // break is the author's line width and means nothing, so it
477
- // closes up into a space.
478
- value = strings.TrimSpace(value + " " + strings.TrimSpace(line))
479
-
480
- default:
481
- p.fail(i, "only meta bullets belong between the title and the first `##`")
482
-
483
- return -1
484
- }
485
- }
486
-
487
- flush()
488
- p.fail(len(p.lines)-1, "the record has no body: nothing here is under a `##`")
489
-
490
- return -1
491
- }
492
-
493
- // require reports the three bullets a record cannot be read without. They are
494
- // reported against the title rather than against the missing line, because
495
- // there is no missing line to point at.
496
- func (p *parser) require(at int, seen map[string]bool) {
497
- for _, key := range []string{"Status", "Date"} {
498
- if !seen[key] {
499
- p.fail(at, "the record says no "+strconv.Quote(key))
500
- }
501
- }
502
- // A record that does not say what it is about is about what the step says
503
- // its tree is about; only when the step says nothing either is that a
504
- // mistake.
505
- switch {
506
- case seen["Scope"]:
507
- case strings.TrimSpace(p.d.Scope) != "":
508
- p.scope(at, p.d.Scope)
509
- default:
510
- p.fail(at, `the record says no "Scope"`)
511
- }
512
-
513
- p.checkSupersession(at)
514
- }
515
-
516
- // checkSupersession holds the two things a record says about being replaced
517
- // against each other. Supersession is a two-way fact and half of it recorded
518
- // is a bug. The other half - that the record named actually names this one
519
- // back - can only be checked once every record is read, so it is checked
520
- // there.
521
- func (p *parser) checkSupersession(at int) {
522
- if p.adr.Status == catalog.AdrSuperseded && p.adr.SupersededBy == "" {
523
- p.fail(at, "the record is superseded and says by what")
524
- }
525
- if p.adr.SupersededBy != "" && p.adr.Status != catalog.AdrSuperseded {
526
- p.fail(at, "the record is superseded by "+p.adr.SupersededBy+` but its status is "`+string(p.adr.Status)+`"`)
527
- }
528
- }
529
-
530
- func (p *parser) bullet(at int, key, value string) {
531
- switch key {
532
- case "Status":
533
- status, ok := statuses[value]
534
- if !ok {
535
- p.fail(at, strconv.Quote(value)+" is not a status: proposed, accepted, superseded, deprecated or rejected")
536
-
537
- return
538
- }
539
- p.adr.Status = status
540
-
541
- case "Date":
542
- if _, err := time.Parse("2006-01-02", value); err != nil {
543
- p.fail(at, strconv.Quote(value)+" is not a date, as in 2026-08-22")
544
-
545
- return
546
- }
547
- p.adr.Date = value
548
-
549
- case "Scope":
550
- p.scope(at, value)
551
-
552
- case "Superseded by":
553
- if ids := p.ids(at, key, value); len(ids) > 1 {
554
- p.fail(at, "a record is superseded by one record, not "+strconv.Itoa(len(ids)))
555
- } else if len(ids) == 1 {
556
- p.adr.SupersededBy = ids[0]
557
- }
558
-
559
- case "Supersedes":
560
- p.adr.Supersedes = p.ids(at, key, value)
561
-
562
- case "Relates":
563
- p.relates(at, value)
564
-
565
- case "Note":
566
- p.adr.Note = value
567
-
568
- default:
569
- p.fail(at, strconv.Quote(key)+" is not one of the bullets a record carries")
570
- }
571
- }
572
-
573
- // scope says what the record is about, and the number of segments says which
574
- // of the three kinds it is: none for the organisation, one for a context, two
575
- // for a service. Whether the thing named exists is not a question this side
576
- // can answer - an extractor sees one service's tree - so it is left to the
577
- // validator, which sees the merged catalog.
578
- func (p *parser) scope(at int, value string) {
579
- value = strings.TrimSpace(value)
580
- if value == "" || value == "org" {
581
- p.adr.Scope = catalog.AdrScope{Kind: "org"}
582
-
583
- return
584
- }
585
- if !scopeValue.MatchString(value) {
586
- p.fail(at, strconv.Quote(value)+` is not a scope: "org", a context, or "<context>.<service>"`)
587
-
588
- return
589
- }
590
- if strings.Contains(value, ".") {
591
- p.adr.Scope = catalog.AdrScope{Kind: "service", Service: value}
592
-
593
- return
594
- }
595
- p.adr.Scope = catalog.AdrScope{Kind: "context", Context: value}
596
- }
597
-
598
- // relates names events, services and flows in one comma-separated list and
599
- // they are told apart by their shape: a flow by its slug, which has no dots; a
600
- // service by `<context>.<service>`; an event by the aggregate and Name after
601
- // that. A name matching none of the three is a typo, and reported here rather
602
- // than left to come back from the validator as a reference to something that
603
- // does not exist.
604
- func (p *parser) relates(at int, value string) {
605
- for _, id := range split(value) {
606
- switch {
607
- case eventID.MatchString(id):
608
- p.adr.Relates.Events = append(p.adr.Relates.Events, id)
609
- case serviceID.MatchString(id):
610
- p.adr.Relates.Services = append(p.adr.Relates.Services, id)
611
- case flowSlug.MatchString(id):
612
- p.adr.Relates.Flows = append(p.adr.Relates.Flows, id)
613
- default:
614
- p.fail(at, strconv.Quote(id)+" is not the shape of an event, a service or a flow")
615
- }
616
- }
617
- }
618
-
619
- func (p *parser) ids(at int, key, value string) []string {
620
- var ids []string
621
- for _, id := range split(value) {
622
- if !adrID.MatchString(id) {
623
- p.fail(at, strconv.Quote(id)+" in "+strconv.Quote(key)+" is not a record's id")
624
-
625
- continue
626
- }
627
- ids = append(ids, id)
628
- }
629
-
630
- return ids
631
- }
632
-
633
- func split(value string) []string {
634
- var out []string
635
- for _, part := range strings.Split(value, ",") {
636
- if part = strings.TrimSpace(part); part != "" {
637
- out = append(out, part)
638
- }
639
- }
640
-
641
- return out
642
- }