@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
@@ -0,0 +1,353 @@
1
+ // CI and publication recipes installed from Settings while Portolan is running
2
+ // locally. Every generated region identifies itself, so a later Portolan
3
+ // version can update its own work without taking ownership of the user's CI.
4
+
5
+ import { execFileSync } from "node:child_process";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import {
8
+ lstatSync,
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ readFileSync,
12
+ renameSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { dirname, join, relative, resolve, sep } from "node:path";
18
+
19
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
20
+ const VERSION = packageJson.version;
21
+ const GENERATED = "# Generated by Portolan. Preview changes in Settings before updating.";
22
+ const GITLAB_START = "# >>> Portolan delivery preset >>>";
23
+ const GITLAB_END = "# <<< Portolan delivery preset <<<";
24
+ const PROVIDERS = new Set(["github", "gitlab"]);
25
+
26
+ function exists(path) {
27
+ try { lstatSync(path); return true; } catch { return false; }
28
+ }
29
+
30
+ function git(workspace, args) {
31
+ try {
32
+ return execFileSync("git", args, {
33
+ cwd: workspace,
34
+ encoding: "utf8",
35
+ stdio: ["ignore", "pipe", "ignore"],
36
+ timeout: 5_000,
37
+ }).trim();
38
+ } catch {
39
+ return "";
40
+ }
41
+ }
42
+
43
+ export function providerFromRemote(remote) {
44
+ const value = String(remote ?? "").trim().toLowerCase();
45
+ if (!value) return null;
46
+ const host = /^(?:https?|ssh):\/\/(?:[^@/]+@)?([^/:]+)/.exec(value)?.[1]
47
+ ?? /^[^@]+@([^:]+):/.exec(value)?.[1]
48
+ ?? "";
49
+ if (host === "github.com" || host.endsWith(".github.com")) return "github";
50
+ if (host === "gitlab.com" || host.includes("gitlab")) return "gitlab";
51
+ return null;
52
+ }
53
+
54
+ function repositoryName(remote, workspace) {
55
+ const clean = String(remote ?? "").replace(/\/+$/, "").replace(/\.git$/, "");
56
+ return clean.split(/[/:]/).filter(Boolean).pop() || relative(dirname(workspace), workspace) || "repository";
57
+ }
58
+
59
+ function defaultBranch(workspace) {
60
+ const remoteHead = git(workspace, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
61
+ if (remoteHead.startsWith("origin/")) return remoteHead.slice("origin/".length);
62
+ for (const candidate of ["main", "master"]) {
63
+ if (git(workspace, ["rev-parse", "--verify", `refs/heads/${candidate}`])) return candidate;
64
+ }
65
+ return git(workspace, ["branch", "--show-current"]) || "main";
66
+ }
67
+
68
+ export function detectDeliveryForge(workspace) {
69
+ const remote = git(workspace, ["config", "--get", "remote.origin.url"]);
70
+ return {
71
+ provider: providerFromRemote(remote),
72
+ remote: remote || null,
73
+ repository: repositoryName(remote, workspace),
74
+ defaultBranch: defaultBranch(workspace),
75
+ };
76
+ }
77
+
78
+ function githubCheck(branch) {
79
+ return `${GENERATED}
80
+ name: Portolan architecture check
81
+
82
+ on:
83
+ pull_request:
84
+ push:
85
+ branches: [${branch}]
86
+
87
+ permissions:
88
+ contents: read
89
+
90
+ concurrency:
91
+ group: portolan-check-\${{ github.ref }}
92
+ cancel-in-progress: true
93
+
94
+ jobs:
95
+ check:
96
+ runs-on: ubuntu-latest
97
+ steps:
98
+ - uses: actions/checkout@v7
99
+ with:
100
+ fetch-depth: 0
101
+ - uses: shortlink-org/portolan@${VERSION}
102
+ with:
103
+ command: check
104
+ version: ${VERSION}
105
+ `;
106
+ }
107
+
108
+ function githubPages(branch) {
109
+ return `${GENERATED}
110
+ name: Publish Portolan catalog
111
+
112
+ on:
113
+ push:
114
+ branches: [${branch}]
115
+ workflow_dispatch:
116
+
117
+ permissions:
118
+ contents: read
119
+ pages: write
120
+ id-token: write
121
+
122
+ concurrency:
123
+ group: portolan-pages
124
+ cancel-in-progress: false
125
+
126
+ jobs:
127
+ build:
128
+ runs-on: ubuntu-latest
129
+ steps:
130
+ - uses: actions/checkout@v7
131
+ with:
132
+ fetch-depth: 0
133
+ - uses: shortlink-org/portolan@${VERSION}
134
+ with:
135
+ command: build
136
+ version: ${VERSION}
137
+ output: dist
138
+ base: /\${{ github.event.repository.name }}/
139
+ - uses: actions/configure-pages@v6
140
+ - uses: actions/upload-pages-artifact@v5
141
+ with:
142
+ path: dist
143
+
144
+ deploy:
145
+ needs: build
146
+ runs-on: ubuntu-latest
147
+ environment:
148
+ name: github-pages
149
+ url: \${{ steps.deployment.outputs.page_url }}
150
+ steps:
151
+ - id: deployment
152
+ uses: actions/deploy-pages@v5
153
+ `;
154
+ }
155
+
156
+ function gitlabBlock() {
157
+ return `${GITLAB_START}
158
+ "portolan:check":
159
+ stage: .pre
160
+ image:
161
+ name: ghcr.io/shortlink-org/portolan:${VERSION}
162
+ entrypoint: [""]
163
+ variables:
164
+ GIT_DEPTH: "0"
165
+ script:
166
+ - portolan check
167
+ rules:
168
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
169
+ - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
170
+
171
+ "portolan:pages":
172
+ stage: .post
173
+ image:
174
+ name: ghcr.io/shortlink-org/portolan:${VERSION}
175
+ entrypoint: [""]
176
+ variables:
177
+ GIT_DEPTH: "0"
178
+ script:
179
+ - BASE_PATH="$(node -p 'new URL(process.env.CI_PAGES_URL).pathname.replace(/\\/?$/, "/") || "/"')"
180
+ - portolan build --output dist --base "$BASE_PATH"
181
+ pages:
182
+ publish: dist
183
+ rules:
184
+ - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
185
+ ${GITLAB_END}
186
+ `;
187
+ }
188
+
189
+ function mergeGitlab(existing) {
190
+ const start = existing.indexOf(GITLAB_START);
191
+ const end = existing.indexOf(GITLAB_END);
192
+ if ((start === -1) !== (end === -1) || (start !== -1 && end < start)) {
193
+ return { conflict: "The existing Portolan managed region is incomplete." };
194
+ }
195
+ if (start !== -1) {
196
+ const after = end + GITLAB_END.length;
197
+ return { content: `${existing.slice(0, start)}${gitlabBlock().trimEnd()}${existing.slice(after)}`.replace(/\n*$/, "\n") };
198
+ }
199
+ if (/^[ \t]*["']?portolan:(?:check|pages)["']?\s*:/m.test(existing)) {
200
+ return { conflict: "This pipeline already declares a Portolan job outside the managed region." };
201
+ }
202
+ return { content: `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${gitlabBlock()}` };
203
+ }
204
+
205
+ function textDiff(path, before, after) {
206
+ if (before === after) return "";
207
+ const holder = mkdtempSync(join(tmpdir(), "portolan-preset-diff-"));
208
+ const left = join(holder, "before");
209
+ const right = join(holder, "after");
210
+ try {
211
+ writeFileSync(left, before);
212
+ writeFileSync(right, after);
213
+ let output = "";
214
+ try {
215
+ output = execFileSync("git", ["diff", "--no-index", "--no-ext-diff", "--unified=3", "--", left, right], {
216
+ encoding: "utf8",
217
+ maxBuffer: 2 * 1024 * 1024,
218
+ });
219
+ } catch (cause) {
220
+ if (cause?.status !== 1) throw cause;
221
+ output = String(cause.stdout ?? "");
222
+ }
223
+ return output
224
+ .replaceAll(`a${left}`, `a/${path}`)
225
+ .replaceAll(`b${right}`, `b/${path}`)
226
+ .replaceAll(left, path)
227
+ .replaceAll(right, path);
228
+ } finally {
229
+ rmSync(holder, { recursive: true, force: true });
230
+ }
231
+ }
232
+
233
+ function readTarget(workspace, path) {
234
+ const target = resolve(workspace, path);
235
+ const root = resolve(workspace);
236
+ if (target !== root && !target.startsWith(`${root}${sep}`)) throw new Error("Preset target must stay inside this repository.");
237
+ let cursor = root;
238
+ for (const segment of relative(root, target).split(sep).filter(Boolean)) {
239
+ cursor = join(cursor, segment);
240
+ if (exists(cursor) && lstatSync(cursor).isSymbolicLink()) return { exists: true, content: "", safe: false };
241
+ }
242
+ if (!exists(target)) return { exists: false, content: "", safe: true };
243
+ const stat = lstatSync(target);
244
+ if (!stat.isFile() || stat.isSymbolicLink()) return { exists: true, content: "", safe: false };
245
+ if (stat.size > 512_000) return { exists: true, content: "", safe: false };
246
+ const bytes = readFileSync(target);
247
+ if (bytes.includes(0)) return { exists: true, content: "", safe: false };
248
+ return { exists: true, content: bytes.toString("utf8"), safe: true };
249
+ }
250
+
251
+ function githubFile(workspace, path, content) {
252
+ const current = readTarget(workspace, path);
253
+ const managed = current.safe && current.content.startsWith(GENERATED);
254
+ const status = !current.exists ? "added" : !current.safe || !managed ? "conflict" : current.content === content ? "unchanged" : "changed";
255
+ return {
256
+ path,
257
+ status,
258
+ message: status === "conflict" ? "A file already exists here and was not created by Portolan." : undefined,
259
+ diff: current.safe ? textDiff(path, current.content, content) : "Existing file is not a safe UTF-8 file.",
260
+ current: current.content,
261
+ next: content,
262
+ };
263
+ }
264
+
265
+ function gitlabFile(workspace) {
266
+ const path = ".gitlab-ci.yml";
267
+ const current = readTarget(workspace, path);
268
+ if (!current.safe) return { path, status: "conflict", message: "The existing pipeline is not a safe UTF-8 file.", diff: "", current: "", next: "" };
269
+ const merged = mergeGitlab(current.content);
270
+ if (merged.conflict) return { path, status: "conflict", message: merged.conflict, diff: "", current: current.content, next: "" };
271
+ const status = !current.exists ? "added" : current.content === merged.content ? "unchanged" : "changed";
272
+ return { path, status, diff: textDiff(path, current.content, merged.content), current: current.content, next: merged.content };
273
+ }
274
+
275
+ function revisionFor(provider, files) {
276
+ const hash = createHash("sha256").update(provider);
277
+ for (const file of files) {
278
+ hash.update("\0"); hash.update(file.path); hash.update("\0"); hash.update(file.current); hash.update("\0"); hash.update(file.next);
279
+ }
280
+ return hash.digest("hex");
281
+ }
282
+
283
+ export function planDeliveryPreset(workspace, requestedProvider) {
284
+ const detected = detectDeliveryForge(workspace);
285
+ const provider = requestedProvider || detected.provider || "github";
286
+ if (!PROVIDERS.has(provider)) throw new Error("Delivery preset must be github or gitlab.");
287
+ const files = provider === "github"
288
+ ? [
289
+ githubFile(workspace, ".github/workflows/portolan-check.yml", githubCheck(detected.defaultBranch)),
290
+ githubFile(workspace, ".github/workflows/portolan-pages.yml", githubPages(detected.defaultBranch)),
291
+ ]
292
+ : [gitlabFile(workspace)];
293
+ const status = files.some((file) => file.status === "conflict")
294
+ ? "conflict"
295
+ : files.every((file) => file.status === "unchanged")
296
+ ? "installed"
297
+ : files.some((file) => file.status === "changed") && (provider !== "gitlab" || files[0].current.includes(GITLAB_START))
298
+ ? "update"
299
+ : "available";
300
+ return {
301
+ provider,
302
+ detectedProvider: detected.provider,
303
+ remote: detected.remote,
304
+ repository: detected.repository,
305
+ defaultBranch: detected.defaultBranch,
306
+ status,
307
+ revision: revisionFor(provider, files),
308
+ features: ["Validate generated architecture in reviews", "Publish the static catalog"],
309
+ files,
310
+ };
311
+ }
312
+
313
+ export function publicDeliveryPreset(plan) {
314
+ return { ...plan, files: plan.files.map(({ current: _current, next: _next, ...file }) => file) };
315
+ }
316
+
317
+ export function installDeliveryPreset(workspace, request) {
318
+ const plan = planDeliveryPreset(workspace, request.provider);
319
+ if (!request.revision || request.revision !== plan.revision) {
320
+ const error = new Error("Files changed after this preset preview. Preview it again.");
321
+ error.status = 409;
322
+ throw error;
323
+ }
324
+ const conflict = plan.files.find((file) => file.status === "conflict");
325
+ if (conflict) {
326
+ const error = new Error(`${conflict.path}: ${conflict.message}`);
327
+ error.status = 409;
328
+ throw error;
329
+ }
330
+ for (const file of plan.files) {
331
+ const current = readTarget(workspace, file.path);
332
+ if (!current.safe || current.content !== file.current) {
333
+ const error = new Error("Files changed after this preset preview. Preview it again.");
334
+ error.status = 409;
335
+ throw error;
336
+ }
337
+ }
338
+ const written = [];
339
+ for (const file of plan.files) {
340
+ if (file.status === "unchanged") continue;
341
+ const target = resolve(workspace, file.path);
342
+ mkdirSync(dirname(target), { recursive: true });
343
+ const temporary = join(dirname(target), `.portolan-${randomUUID()}.tmp`);
344
+ try {
345
+ writeFileSync(temporary, file.next, { flag: "wx" });
346
+ renameSync(temporary, target);
347
+ } finally {
348
+ if (exists(temporary)) rmSync(temporary, { force: true });
349
+ }
350
+ written.push(file.path);
351
+ }
352
+ return { ...publicDeliveryPreset(planDeliveryPreset(workspace, request.provider)), written };
353
+ }
package/scripts/gen.mjs CHANGED
@@ -26,8 +26,9 @@ import {
26
26
  writeBuildReport,
27
27
  } from "./build-report.mjs";
28
28
  import { loadCatalog } from "./catalog-sources.mjs";
29
+ import { historyFor } from "./history.mjs";
29
30
  import { loadManifest, stepKeys } from "./manifest.mjs";
30
- import { runPlugin } from "./plugin-host.mjs";
31
+ import { describePlugin, runPlugin } from "./plugin-host.mjs";
31
32
  import { vendoredCommit } from "./vendor-lock.mjs";
32
33
  import {
33
34
  removeOutputFile,
@@ -73,6 +74,8 @@ const report = createBuildReport({
73
74
  let manifest = {};
74
75
  let keys = stepKeys({});
75
76
  let drifted = false;
77
+ // What each plugin said it needs from the host (portolan.0007), by name.
78
+ const pluginNeeds = new Map();
76
79
 
77
80
  // What each step wrote into each directory on this run, by key. The listing
78
81
  // on disk says what was written last time; in check mode it is never updated,
@@ -122,12 +125,22 @@ async function generate() {
122
125
  for (const step of manifest.extract ?? []) {
123
126
  const plugin = pluginNamed(step.plugin);
124
127
  const stamp = stampFor(step.in, step.out);
128
+ // A plugin that asks for history gets the root's, read once per checkout
129
+ // (portolan.0007); left out when the root is not in a checkout, which the
130
+ // plugin reports in its own words.
131
+ const history = (await needsOf(plugin)).has("history") ? historyFor(process.cwd(), step.in) : undefined;
125
132
  await executeStep("extract", step, `${step.plugin} ← ${step.in}`, async () =>
126
133
  runPlugin(plugin, {
127
134
  portolanVersion: PORTOLAN_VERSION,
128
- input: { root: step.in, output: step.out, commit: stamp.commit, generatedAt: stamp.generatedAt },
135
+ input: {
136
+ root: step.in,
137
+ output: step.out,
138
+ commit: stamp.commit,
139
+ generatedAt: stamp.generatedAt,
140
+ ...(history ? { history } : {}),
141
+ },
129
142
  options: step.options ?? {},
130
- }),
143
+ }, {}, { workspace: process.cwd() }),
131
144
  );
132
145
  }
133
146
 
@@ -148,7 +161,7 @@ async function generate() {
148
161
  input: { root: step.in, output: step.out, commit: stamp.commit, generatedAt: stamp.generatedAt },
149
162
  catalog,
150
163
  options: step.options ?? {},
151
- }),
164
+ }, {}, { workspace: process.cwd() }),
152
165
  );
153
166
  }
154
167
 
@@ -301,6 +314,26 @@ function pluginNamed(name) {
301
314
  return plugin ?? shipped;
302
315
  }
303
316
 
317
+ /**
318
+ * What a plugin's descriptor says it needs from the host, asked once per
319
+ * plugin per run. A plugin that does not describe itself needs nothing it
320
+ * could be given.
321
+ */
322
+
323
+ async function needsOf(plugin) {
324
+ if (!pluginNeeds.has(plugin.name)) {
325
+ let descriptor = null;
326
+ try {
327
+ descriptor = await describePlugin(plugin);
328
+ } catch {
329
+ // A plugin that cannot answer describe cannot ask for anything either;
330
+ // whatever is wrong with it, the step itself will say.
331
+ }
332
+ pluginNeeds.set(plugin.name, new Set(descriptor?.needs ?? []));
333
+ }
334
+ return pluginNeeds.get(plugin.name);
335
+ }
336
+
304
337
  /** Prints what a step did, and says whether it left the tree out of date. */
305
338
  function summarise(label, files, changes, warnings = []) {
306
339
  const said = warnings.length > 0 ? `, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}` : "";
@@ -0,0 +1,126 @@
1
+ // When each file was first committed and last changed, read by the host for a
2
+ // plugin that asks (portolan.0007).
3
+ //
4
+ // One `git log` over the whole checkout, walked oldest first with renames
5
+ // followed, gives every path its first commit and its last. A plugin used to
6
+ // ask git this per file with `--follow`; a wasm module cannot run git, and
7
+ // the answer is a fact about the checkout the host already holds, so the host
8
+ // reads it once and hands each step the part under its root.
9
+
10
+ import { execFileSync } from "node:child_process";
11
+ import { realpathSync } from "node:fs";
12
+ import { relative, resolve, sep } from "node:path";
13
+
14
+ const RECORD = "\x1e";
15
+ const FIELD = "\x1f";
16
+
17
+ /** Per repository, per process: the history does not change during a run. */
18
+ const cache = new Map();
19
+
20
+ /**
21
+ * History for every file under `root`, keyed by the path a plugin would name
22
+ * it - relative to `workspace`, forward slashes. `undefined` when the root is
23
+ * not inside a git checkout, which is different from an empty object: a
24
+ * checkout with nothing committed under the root.
25
+ *
26
+ * @param {string} workspace
27
+ * @param {string} root
28
+ * @returns {Record<string, {created: Commit, revised?: Commit}> | undefined}
29
+ */
30
+ export function historyFor(workspace, root) {
31
+ // Resolved through symlinks, as git reports its top level: on macOS a
32
+ // temporary directory is one and the two would never compare equal.
33
+ const workspaceReal = realpathSync(resolve(workspace));
34
+ const absoluteRoot = resolve(workspaceReal, root);
35
+ const repo = repositoryOf(absoluteRoot);
36
+ if (!repo) return undefined;
37
+
38
+ const rootKey = toPosix(relative(workspaceReal, absoluteRoot));
39
+ const under = (key) => rootKey === "" || rootKey === "." || key === rootKey || key.startsWith(`${rootKey}/`);
40
+ const history = {};
41
+ for (const [pathInRepo, entry] of fileHistory(repo)) {
42
+ const key = toPosix(relative(workspaceReal, resolve(repo, pathInRepo)));
43
+ if (!key || key.startsWith("..") || !under(key)) continue;
44
+ history[key] = entry.revised ? { created: entry.created, revised: entry.revised } : { created: entry.created };
45
+ }
46
+ return history;
47
+ }
48
+
49
+ /**
50
+ * Every path in the checkout with its first and last commit, renames
51
+ * followed: a file moved keeps the commit that first added it under its old
52
+ * name, and the move counts as a revision. Paths are relative to the
53
+ * repository, forward slashes.
54
+ *
55
+ * @param {string} repo
56
+ * @returns {Map<string, {created: Commit, revised: Commit | null}>}
57
+ */
58
+ export function fileHistory(repo) {
59
+ const key = resolve(repo);
60
+ if (cache.has(key)) return cache.get(key);
61
+ const log = git(key, [
62
+ "log", "--reverse", "--name-status", "-M", "--no-color",
63
+ `--format=${RECORD}%H${FIELD}%an${FIELD}%cI`,
64
+ ]);
65
+ const history = parseLog(log);
66
+ cache.set(key, history);
67
+ return history;
68
+ }
69
+
70
+ /** @param {string} log */
71
+ export function parseLog(log) {
72
+ const history = new Map();
73
+ for (const record of log.split(RECORD)) {
74
+ if (!record.trim()) continue;
75
+ const [header, ...lines] = record.split("\n");
76
+ const [sha, author, date] = header.split(FIELD);
77
+ if (!sha) continue;
78
+ const commit = { commit: sha, author, date };
79
+ for (const line of lines) {
80
+ const match = /^([A-Z])\d*\t([^\t]+)(?:\t([^\t]+))?$/.exec(line);
81
+ if (!match) continue;
82
+ const [, status, first, second] = match;
83
+ if (status === "D") {
84
+ history.delete(first);
85
+ } else if (status === "R" && second) {
86
+ const moved = history.get(first) ?? { created: commit, revised: null };
87
+ history.delete(first);
88
+ history.set(second, { created: moved.created, revised: commit });
89
+ } else {
90
+ const target = status === "C" && second ? second : first;
91
+ const known = history.get(target);
92
+ if (known) history.set(target, { created: known.created, revised: commit });
93
+ else history.set(target, { created: commit, revised: null });
94
+ }
95
+ }
96
+ }
97
+ return history;
98
+ }
99
+
100
+ /** Forget what was read, for a test that commits between two reads. */
101
+ export function forgetHistory() {
102
+ cache.clear();
103
+ }
104
+
105
+ /** The working tree a directory belongs to, or "" when no repository holds it. */
106
+ export function repositoryOf(dir) {
107
+ return git(dir, ["rev-parse", "--show-toplevel"]);
108
+ }
109
+
110
+ function git(dir, args) {
111
+ try {
112
+ return execFileSync("git", ["-C", dir, ...args], {
113
+ encoding: "utf8",
114
+ stdio: ["ignore", "pipe", "ignore"],
115
+ maxBuffer: 256 * 1024 * 1024,
116
+ }).trim();
117
+ } catch {
118
+ return "";
119
+ }
120
+ }
121
+
122
+ function toPosix(path) {
123
+ return path.split(sep).join("/");
124
+ }
125
+
126
+ /** @typedef {{commit: string, author: string, date: string}} Commit */
@@ -0,0 +1,110 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { fileHistory, forgetHistory, historyFor, parseLog } from "./history.mjs";
8
+
9
+ const created = [];
10
+ afterEach(() => {
11
+ forgetHistory();
12
+ for (const path of created.splice(0)) rmSync(path, { recursive: true, force: true });
13
+ });
14
+
15
+ const ADA = { GIT_AUTHOR_NAME: "Ada Lovelace", GIT_AUTHOR_EMAIL: "ada@example.com", GIT_COMMITTER_NAME: "Ada Lovelace", GIT_COMMITTER_EMAIL: "ada@example.com" };
16
+ const GRACE = { GIT_AUTHOR_NAME: "Grace Hopper", GIT_AUTHOR_EMAIL: "grace@example.com", GIT_COMMITTER_NAME: "Grace Hopper", GIT_COMMITTER_EMAIL: "grace@example.com" };
17
+
18
+ function repository() {
19
+ const root = mkdtempSync(join(tmpdir(), "portolan-history-"));
20
+ created.push(root);
21
+ const git = (args, who = ADA, date = "2026-01-01T09:00:00Z") =>
22
+ execFileSync("git", ["-C", root, ...args], {
23
+ encoding: "utf8",
24
+ env: { ...process.env, ...who, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
25
+ stdio: ["ignore", "pipe", "pipe"],
26
+ }).trim();
27
+ const write = (name, contents) => {
28
+ mkdirSync(join(root, name, ".."), { recursive: true });
29
+ writeFileSync(join(root, name), contents);
30
+ };
31
+ git(["init", "-q"]);
32
+ return { root, git, write };
33
+ }
34
+
35
+ describe("historyFor", () => {
36
+ it("gives every file its first commit, its last, and follows a move", () => {
37
+ const { root, git, write } = repository();
38
+ write("docs/adr/0001-first.md", "# 1. First\n");
39
+ write("docs/adr/0002-second.md", "# 2. Second\n");
40
+ git(["add", "."]);
41
+ git(["commit", "-q", "-m", "two records"]);
42
+ write("docs/adr/0002-second.md", "# 2. Second\n\nReworded.\n");
43
+ git(["commit", "-q", "-am", "reword"], GRACE, "2026-01-03T17:30:00Z");
44
+ mkdirSync(join(root, "adr"));
45
+ git(["mv", "docs/adr/0001-first.md", "adr/0001-first.md"]);
46
+ git(["commit", "-q", "-m", "move"], GRACE, "2026-01-05T08:00:00Z");
47
+
48
+ const history = historyFor(root, ".");
49
+ expect(Object.keys(history).sort()).toEqual(["adr/0001-first.md", "docs/adr/0002-second.md"]);
50
+
51
+ const second = history["docs/adr/0002-second.md"];
52
+ expect(second.created).toMatchObject({ author: "Ada Lovelace", date: "2026-01-01T09:00:00Z" });
53
+ expect(second.created.commit).toHaveLength(40);
54
+ expect(second.revised).toMatchObject({ author: "Grace Hopper", date: "2026-01-03T17:30:00Z" });
55
+ expect(second.revised.commit).not.toBe(second.created.commit);
56
+
57
+ const moved = history["adr/0001-first.md"];
58
+ expect(moved.created).toMatchObject({ author: "Ada Lovelace", date: "2026-01-01T09:00:00Z" });
59
+ expect(moved.revised).toMatchObject({ author: "Grace Hopper", date: "2026-01-05T08:00:00Z" });
60
+ });
61
+
62
+ it("keeps only the root's files, and no revision for a file committed once", () => {
63
+ const { root, git, write } = repository();
64
+ write("docs/adr/0001-first.md", "# 1. First\n");
65
+ write("README.md", "# Repo\n");
66
+ git(["add", "."]);
67
+ git(["commit", "-q", "-m", "one"]);
68
+
69
+ expect(Object.keys(historyFor(root, "docs/adr"))).toEqual(["docs/adr/0001-first.md"]);
70
+ expect(historyFor(root, "docs/adr")["docs/adr/0001-first.md"]).not.toHaveProperty("revised");
71
+ expect(historyFor(root, "docs")).toHaveProperty("docs/adr/0001-first.md");
72
+ });
73
+
74
+ it("names files as a manifest below the repository root would", () => {
75
+ const { root, git, write } = repository();
76
+ write("services/auth/docs/adr/0001-first.md", "# 1. First\n");
77
+ git(["add", "."]);
78
+ git(["commit", "-q", "-m", "one"]);
79
+
80
+ expect(Object.keys(historyFor(join(root, "services/auth"), "docs/adr"))).toEqual(["docs/adr/0001-first.md"]);
81
+ });
82
+
83
+ it("answers undefined outside a checkout, and nothing for a deleted file", () => {
84
+ const bare = mkdtempSync(join(tmpdir(), "portolan-history-bare-"));
85
+ created.push(bare);
86
+ expect(historyFor(bare, ".")).toBeUndefined();
87
+
88
+ const { root, git, write } = repository();
89
+ write("gone.md", "x\n");
90
+ git(["add", "."]);
91
+ git(["commit", "-q", "-m", "add"]);
92
+ git(["rm", "-q", "gone.md"]);
93
+ git(["commit", "-q", "-m", "remove"]);
94
+ expect(fileHistory(root).size).toBe(0);
95
+ });
96
+ });
97
+
98
+ describe("parseLog", () => {
99
+ it("reads git's record, field and name-status lines", () => {
100
+ const log = [
101
+ "\x1eaaa\x1fAda\x1f2026-01-01T09:00:00Z\n\nA\tdocs/a.md\nA\tdocs/b.md\n",
102
+ "\x1ebbb\x1fGrace\x1f2026-01-02T09:00:00Z\n\nM\tdocs/b.md\nR100\tdocs/a.md\tadr/a.md\nC075\tdocs/b.md\tdocs/c.md\n",
103
+ "\x1eccc\x1fGrace\x1f2026-01-03T09:00:00Z\n\nD\tdocs/c.md\n",
104
+ ].join("");
105
+ const history = parseLog(log);
106
+ expect([...history.keys()].sort()).toEqual(["adr/a.md", "docs/b.md"]);
107
+ expect(history.get("adr/a.md")).toEqual({ created: { commit: "aaa", author: "Ada", date: "2026-01-01T09:00:00Z" }, revised: { commit: "bbb", author: "Grace", date: "2026-01-02T09:00:00Z" } });
108
+ expect(history.get("docs/b.md").revised.commit).toBe("bbb");
109
+ });
110
+ });