@shortlink-org/portolan 0.2.4 → 0.4.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 (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
@@ -10,11 +10,18 @@ import {
10
10
  technologiesOf,
11
11
  } from "../catalog";
12
12
  import { flowRoles } from "../lib/derive";
13
+ import { bridges } from "../lib/centrality";
13
14
  import { treeHref } from "../lib/source-link";
14
15
  import { flowHealth } from "../lib/flow-tree";
15
16
  import { adrsForService, isCurrent } from "../lib/adr";
16
17
  import { ADR_ROW_COLUMNS, AdrRow } from "../components/AdrRow";
17
- import { EVENT_ANCHOR, SERVICE_ANCHOR, aggregatePath, paths } from "../routes";
18
+ import {
19
+ EVENT_ANCHOR,
20
+ OVERVIEW_ANCHOR,
21
+ SERVICE_ANCHOR,
22
+ aggregatePath,
23
+ paths,
24
+ } from "../routes";
18
25
  import { Markdown } from "../components/Markdown";
19
26
  import { middleTruncate, plural } from "../lib/format";
20
27
  import { Empty, PageHeader, SectionTitle } from "../components/PageHeader";
@@ -31,6 +38,8 @@ import {
31
38
  hasAsyncSpec,
32
39
  } from "../components/AsyncApiReference";
33
40
  import { ChannelRows } from "../components/ChannelRows";
41
+ import { DocsLinks } from "../components/DocsLinks";
42
+ import { DeploymentRows } from "../components/DeploymentRows";
34
43
  import { CommandRows } from "../components/CommandRows";
35
44
  import { ModuleSpec } from "../components/SourceDoc";
36
45
  import { hasSchema, SchemaDocument } from "../components/SchemaDocument";
@@ -45,7 +54,11 @@ import { NotFound } from "./NotFound";
45
54
  import { C4View } from "../likec4/C4View";
46
55
  import { LevelSwitch } from "../likec4/levels";
47
56
  import type { C4Level } from "../likec4/levels";
48
- import { serviceInsideViewId, serviceViewId } from "../likec4/ids";
57
+ import {
58
+ serviceDeployViewId,
59
+ serviceInsideViewId,
60
+ serviceViewId,
61
+ } from "../likec4/ids";
49
62
  import { index } from "../data";
50
63
  import { storesOfService } from "../lib/data-model";
51
64
  import { ErCanvas } from "../er/ErCanvas";
@@ -112,6 +125,11 @@ export function ServicePage() {
112
125
  const flows = flowRoles(catalog, service.id);
113
126
  const tree = treeHref(service.path, service, allRepos(catalog));
114
127
  const owners = ownersOf(service);
128
+ const deployments = index.deploymentsByService.get(service.id) ?? [];
129
+ // The service as a road: the pairs of contexts it stands between, if any.
130
+ // Derived from the whole estate, so it is computed here and not carried
131
+ // on the service, which knows only its own edges.
132
+ const road = bridges(catalog).find((b) => b.id === service.id) ?? null;
115
133
  const adrs = adrsForService(catalog, service.id, context.id);
116
134
  const current = adrs.filter(isCurrent);
117
135
  const retired = adrs.filter((a) => !isCurrent(a));
@@ -130,6 +148,7 @@ export function ServicePage() {
130
148
  .find((source) => source !== undefined && hasAsyncSpec(source));
131
149
  const showDomain =
132
150
  componentKind(service) === "service" || service.aggregates.length > 0;
151
+ const hasModelGroups = service.aggregates.some((aggregate) => aggregate.kind === "model-group");
133
152
  const integrations = integrationsFor(service, catalog);
134
153
 
135
154
  const counts: Record<Tab, number | null> = {
@@ -167,6 +186,17 @@ export function ServicePage() {
167
186
  right={
168
187
  <>
169
188
  <span className="chip-lg">{componentKind(service)}</span>
189
+ {road ? (
190
+ <Link
191
+ to={`${paths.overview()}#${OVERVIEW_ANCHOR.bridges}`}
192
+ className="chip-lg border-line text-muted hover:text-ink"
193
+ title={`${Math.round(road.betweenness * 100)}% of the shortest paths between other services run through this one — see the bridges on the overview`}
194
+ >
195
+ bridge
196
+ <span className="text-muted">·</span>
197
+ {road.between.map((pair) => `${pair.a} ↔ ${pair.b}`).join(", ")}
198
+ </Link>
199
+ ) : null}
170
200
  <ContextPill id={context.id} name={context.name} />
171
201
  </>
172
202
  }
@@ -185,6 +215,7 @@ export function ServicePage() {
185
215
  open ↗
186
216
  </a>
187
217
  ) : null}
218
+ <DocsLinks name={service.name} />
188
219
  {/* Who to ask, read out of CODEOWNERS. A handle is copied rather
189
220
  than linked: it is what a reviewer types into a request, and a
190
221
  link to a team page is a page most readers cannot open. */}
@@ -219,7 +250,7 @@ export function ServicePage() {
219
250
  className="rounded-control hover:text-ink"
220
251
  >
221
252
  <span className="tnum">{service.aggregates.length}</span>{" "}
222
- {plural(service.aggregates.length, "aggregate")}
253
+ {plural(service.aggregates.length, hasModelGroups ? "group" : "aggregate")}
223
254
  </a>
224
255
  ) : null}
225
256
  {showDomain ? (
@@ -306,13 +337,39 @@ export function ServicePage() {
306
337
  />
307
338
  </section>
308
339
  ) : null}
340
+ {/* Where it runs, read from the deployer's snapshot. Beside the
341
+ commands because it answers the next question after "how do
342
+ I build it": which commit stands where. Absent entirely when
343
+ no snapshot places it - a heading over an empty list would
344
+ claim the service runs nowhere, and nobody made that claim. */}
345
+ {deployments.length > 0 ? (
346
+ <section
347
+ id={SERVICE_ANCHOR.deployments}
348
+ className="mt-section max-w-table"
349
+ >
350
+ <SectionTitle anchor={SERVICE_ANCHOR.deployments}>
351
+ Where it runs
352
+ </SectionTitle>
353
+ <DeploymentRows deployments={deployments} />
354
+ {/* The same rows as a picture: the service's instances with
355
+ the environment, cluster and namespace drawn as the
356
+ frames they are. Under the rows, because the rows carry
357
+ the commit and the images and the picture carries where. */}
358
+ <div className="mt-3">
359
+ <C4View
360
+ viewId={serviceDeployViewId(service)}
361
+ height={220}
362
+ />
363
+ </div>
364
+ </section>
365
+ ) : null}
309
366
  {showDomain ? (
310
367
  <section
311
368
  id={SERVICE_ANCHOR.aggregates}
312
369
  className="mt-section max-w-table"
313
370
  >
314
371
  <SectionTitle anchor={SERVICE_ANCHOR.aggregates}>
315
- Aggregates
372
+ {hasModelGroups ? "Aggregates and model groups" : "Aggregates"}
316
373
  </SectionTitle>
317
374
  {/* icon, slug, name, event count, actions - one column each,
318
375
  so the counts stack up instead of drifting with the name. */}
@@ -336,7 +393,7 @@ export function ServicePage() {
336
393
  >
337
394
  {aggregate.slug}
338
395
  </Link>
339
- <span className="meta">{aggregate.name}</span>
396
+ <span className="meta">{aggregate.name}{aggregate.kind === "model-group" ? " · model group" : ""}</span>
340
397
  <Link
341
398
  to={`${to}#bb-events`}
342
399
  className="mono rounded-control hover:underline"
@@ -48,17 +48,26 @@ import {
48
48
  import type { Discovery, ProjectDraft, ProjectPlan, RunEvent } from "../lib/local-api";
49
49
  import { useQuery, useQueryClient } from "@tanstack/react-query";
50
50
  import { localKeys, localStatusQuery } from "../lib/queries";
51
- import { sourceHref, treeHref } from "../lib/source-link";
51
+ import { pluginByName, pluginIcon } from "../lib/plugins";
52
+ import { PluginIcon } from "../components/PluginIcon";
53
+ import { bare, sourceHref, treeHref } from "../lib/source-link";
52
54
  import { paths } from "../routes";
53
55
  import { CapabilityEmpty, Empty, SectionTitle } from "../components/PageHeader";
54
56
  import { Modal } from "../components/Overlay";
55
57
  import { MachineDocs } from "../components/MachineDocs";
56
58
  import { DeliverySettings } from "./settings/DeliverySettings";
59
+ import { RecordingSettings } from "./settings/RecordingSettings";
60
+ import { RulesSettings } from "./settings/RulesSettings";
57
61
  import { PreferencesSettings } from "./settings/PreferencesSettings";
58
62
  import { AboutSettings } from "./settings/AboutSettings";
59
63
  import { IntegrationsSettings } from "./settings/IntegrationsSettings";
64
+ import { DjangoAggregateChoices } from "./settings/DjangoAggregateChoices";
65
+ import { djangoAggregateCandidates, djangoAggregateMessage } from "../lib/django-aggregates";
60
66
  import { CatEmptyState, CatIllustration } from "../components/CatIllustration";
61
67
  import { CommitLink } from "../components/CommitLink";
68
+ import { groupDiagnostics, warningDiagnostic } from "../lib/warnings";
69
+ import type { WarningDiagnostic, WarningSeverity } from "../lib/warnings";
70
+ import { AnimatePresence, m, rise } from "../lib/motion";
62
71
 
63
72
  type Health = "healthy" | "changed" | "failed" | "unchecked";
64
73
  type ProjectSource = ProjectDraft["source"];
@@ -261,44 +270,89 @@ function PipelineSteps({ steps }: { steps: SetupRunStep[] }) {
261
270
  if (steps.length === 0) {
262
271
  return <p className="mono text-muted">No result recorded for these steps.</p>;
263
272
  }
273
+ const warnings = steps.flatMap((step) => step.warnings.map((message) => ({ plugin: step.plugin, message })));
274
+ const diagnostics = steps.flatMap((step) => step.diagnostics.length
275
+ ? step.diagnostics
276
+ : step.warnings.map((message) => warningDiagnostic({ plugin: step.plugin, message })));
264
277
  return (
265
- <div className="divide-y divide-line rounded-control border border-line">
266
- {steps.map((step) => (
267
- <div key={step.ordinal} className="px-3 py-2">
268
- <div className="grid gap-2 sm:grid-cols-[5rem_minmax(7rem,1fr)_auto_auto] sm:items-center">
269
- <span className="mono text-muted">{step.phase}</span>
270
- <span className="mono truncate text-ink" title={step.plugin}>{step.plugin}</span>
271
- <span className="mono text-muted">{step.fileCount} {plural(step.fileCount, "file")}</span>
272
- <div className="flex items-center justify-between gap-2 sm:justify-end">
273
- <span className="mono text-muted">{duration(step.durationMs)}</span>
274
- <StepStatus status={step.status} />
278
+ <div className="space-y-2">
279
+ <div className="divide-y divide-line rounded-control border border-line">
280
+ {steps.map((step) => (
281
+ <div key={step.ordinal} className="px-3 py-2">
282
+ <div className="grid gap-2 sm:grid-cols-[5rem_minmax(7rem,1fr)_auto_auto] sm:items-center">
283
+ <span className="mono text-muted">{step.phase}</span>
284
+ <span className="mono truncate text-ink" title={step.plugin}>{step.plugin}</span>
285
+ <span className="mono text-muted">{step.fileCount} {plural(step.fileCount, "file")}</span>
286
+ <div className="flex items-center justify-between gap-2 sm:justify-end">
287
+ <span className="mono text-muted">{duration(step.durationMs)}</span>
288
+ <StepStatus status={step.status} />
289
+ </div>
275
290
  </div>
276
291
  </div>
277
- <StepWarnings warnings={step.warnings} />
278
- </div>
279
- ))}
292
+ ))}
293
+ </div>
294
+ {warnings.length ? <details open={warnings.length <= 5}>
295
+ <summary className="mono cursor-pointer text-muted">{warnings.length} {plural(warnings.length, "warning")}</summary>
296
+ <div className="mt-2"><WarningPanel diagnostics={diagnostics} compact /></div>
297
+ </details> : null}
280
298
  </div>
281
299
  );
282
300
  }
283
301
 
284
- // What a plugin could not read, in its own words. For a tree the extractor
285
- // only half understood this is the list of what to fix, so it is shown where
286
- // the step is rather than left on the terminal it scrolled off.
287
- function StepWarnings({ warnings }: { warnings: string[] }) {
288
- if (warnings.length === 0) return null;
302
+ function WarningPanel({ diagnostics, compact = false }: { diagnostics: WarningDiagnostic[]; compact?: boolean }) {
303
+ const groups = groupDiagnostics(diagnostics);
304
+ const plugins = [...new Set(groups.map((group) => group.plugin))].sort();
305
+ const rules = [...new Set(groups.map((group) => group.rule))].sort();
306
+ const [plugin, setPlugin] = useState("all");
307
+ const [rule, setRule] = useState("all");
308
+ const [severity, setSeverity] = useState<WarningSeverity | "all">("all");
309
+ const [visibility, setVisibility] = useState<"active" | "suppressed" | "all">("active");
310
+ const shown = groups.filter((group) =>
311
+ (plugin === "all" || group.plugin === plugin)
312
+ && (rule === "all" || group.rule === rule)
313
+ && (severity === "all" || group.severity === severity)
314
+ && (visibility === "all" || (visibility === "suppressed") === group.suppressed),
315
+ );
316
+ const activeCount = groups.filter((group) => !group.suppressed).reduce((sum, group) => sum + group.count, 0);
317
+ const suppressedCount = diagnostics.length - activeCount;
318
+
289
319
  return (
290
- <details className="mt-2" open={warnings.length <= 5}>
291
- <summary className="mono cursor-pointer text-muted">
292
- {warnings.length} {plural(warnings.length, "warning")}
293
- </summary>
294
- <ul className="mono mt-1 space-y-1 text-muted">
295
- {warnings.map((warning, index) => (
296
- <li key={`${index}:${warning}`} className="break-words border-l-2 border-line pl-2">
297
- {warning}
298
- </li>
299
- ))}
300
- </ul>
301
- </details>
320
+ <div className="space-y-2">
321
+ <div className="flex flex-wrap items-center gap-2">
322
+ <span className="mono text-muted">{activeCount} active · {groups.length} {plural(groups.length, "rule")}</span>
323
+ {suppressedCount ? <span className="chip status-verified">{suppressedCount} suppressed</span> : null}
324
+ <div className="flex flex-wrap gap-2 sm:ml-auto">
325
+ {plugins.length > 1 ? <select aria-label="Filter warnings by plugin" className={`${FIELD} w-auto py-1`} value={plugin} onChange={(event) => setPlugin(event.target.value)}><option value="all">all plugins</option>{plugins.map((name) => <option key={name} value={name}>{name}</option>)}</select> : null}
326
+ {rules.length > 1 ? <select aria-label="Filter warnings by rule" className={`${FIELD} w-auto py-1`} value={rule} onChange={(event) => setRule(event.target.value)}><option value="all">all rules</option>{rules.map((name) => <option key={name} value={name}>{name}</option>)}</select> : null}
327
+ <select aria-label="Filter warnings by severity" className={`${FIELD} w-auto py-1`} value={severity} onChange={(event) => setSeverity(event.target.value as WarningSeverity | "all")}><option value="all">all severities</option><option value="error">error</option><option value="warning">warning</option><option value="info">info</option></select>
328
+ <select aria-label="Filter active or suppressed warnings" className={`${FIELD} w-auto py-1`} value={visibility} onChange={(event) => setVisibility(event.target.value as "active" | "suppressed" | "all")}><option value="active">active</option><option value="suppressed">suppressed</option><option value="all">all</option></select>
329
+ </div>
330
+ </div>
331
+ {shown.length ? <div className="space-y-2">{shown.map((group) => {
332
+ const color = group.severity === "error" ? "text-unresolved" : group.severity === "warning" ? "text-declared" : "text-muted";
333
+ const candidates = group.messages.filter((warning) => warning.aggregateCandidates || djangoAggregateCandidates(warning.message));
334
+ const examples = group.messages.filter((warning) => !warning.aggregateCandidates && !djangoAggregateCandidates(warning.message)).slice(0, compact ? 3 : 12);
335
+ return <details key={`${group.plugin}:${group.rule}:${group.suppressed}`} className={`rounded-control border px-3 py-2 ${group.suppressed ? "border-line opacity-75" : group.severity === "error" ? "border-unresolved" : "border-line"}`} open={!compact && shown.length <= 4}>
336
+ <summary className="cursor-pointer list-none">
337
+ <span className="flex flex-wrap items-center gap-2">
338
+ <CircleAlert size={15} className={`shrink-0 ${color}`} />
339
+ <span className="mono text-ink">{group.plugin}</span>
340
+ <span className={`chip ${group.severity === "error" ? "status-unresolved" : group.severity === "warning" ? "status-declared" : ""}`}>{group.severity}</span>
341
+ <span className="mono text-muted">{group.rule}</span>
342
+ <span className="chip ml-auto">×{group.count}</span>
343
+ </span>
344
+ <span className="mt-1 block text-muted"><span className="font-medium text-ink">Next:</span> {group.action}</span>
345
+ {group.suppressionReason ? <span className="mt-1 block text-muted"><span className="font-medium text-ink">Suppressed:</span> {group.suppressionReason}</span> : null}
346
+ </summary>
347
+ <ul className="mono mt-2 space-y-1 text-muted">
348
+ {examples.map((warning, index) => <li key={`${index}:${warning.message}`} className="break-words border-l-2 border-line pl-2">{djangoAggregateMessage(warning.message)}</li>)}
349
+ </ul>
350
+ {candidates.length ? <DjangoAggregateChoices warnings={candidates} /> : null}
351
+ {group.count > examples.length + candidates.length ? <p className="mono mt-2 text-faint">{group.count - examples.length - candidates.length} more occurrences</p> : null}
352
+ </details>;
353
+ })}</div> : <p className="rounded-control border border-line px-3 py-2 text-muted">No warnings match these filters.</p>}
354
+ {!compact && groups.some((group) => !group.suppressed) ? <p className="text-faint">Suppress a reviewed limitation with a typed CEL entry in <span className="mono">portolan.json → warningPolicies</span>. The reason is required and remains visible here after regeneration.</p> : null}
355
+ </div>
302
356
  );
303
357
  }
304
358
 
@@ -307,11 +361,22 @@ function ProjectCard({ project, onRemove }: { project: SetupProject; onRemove?:
307
361
  const declared = setupInfo.steps.filter((step) => step.projectId === project.id);
308
362
  const runSteps = setupInfo.run?.steps.filter((step) => step.projectId === project.id) ?? [];
309
363
  const pluginNames = [...new Set(declared.map((step) => step.plugin))];
364
+ // A project rooted at the repository itself owns every source in it.
365
+ const rootedAtRepository = project.root === "." || project.root === "";
310
366
  const sources = catalogSources.filter(
311
- (source) => source.path === project.root || source.path.startsWith(`${project.root}/`),
367
+ (source) => rootedAtRepository || source.path === project.root || source.path.startsWith(`${project.root}/`),
312
368
  );
313
369
  const outputs = [...new Set(runSteps.flatMap((step) => step.files))];
370
+ // A vendored project's provenance is its pin: the upstream commit the copy
371
+ // was fetched at, which is also what its source links open. The history of
372
+ // THIS checkout says when the copy landed here, which is a different fact.
373
+ const pin = project.repository
374
+ ? catalog.repos?.find((held) => bare(held.repo) === bare(project.repository!))
375
+ : undefined;
314
376
  const commits = [...new Set(sources.map((source) => source.commit).filter(Boolean))];
377
+ // "uncommitted" is what the history says of a file it does not hold yet;
378
+ // it is not a commit, and a link built from it would open nothing.
379
+ const committed = commits.filter((commit) => commit !== "uncommitted");
315
380
  const href = projectHref(project);
316
381
  const sourceLink = forge(project);
317
382
  const health = healthFor(runSteps, declared.length, setupInfo);
@@ -342,7 +407,7 @@ function ProjectCard({ project, onRemove }: { project: SetupProject; onRemove?:
342
407
  <dt>scope</dt><dd className="truncate text-ink">{project.components?.length ? `${project.group ?? project.context} · ${project.components.length} components` : [project.group ?? project.context, project.component ?? project.service].filter(Boolean).join(" · ") || "estate"}</dd>
343
408
  <dt>pipeline</dt><dd className="text-ink">{declared.length} {plural(declared.length, "step")}</dd>
344
409
  <dt>fragments</dt><dd className="text-ink">{sources.length}</dd>
345
- <dt>commit</dt><dd className="truncate text-ink" title={commits.join(", ")}>{commits.length === 0 ? "not stamped" : commits.length === 1 ? <CommitLink commit={commits[0]!} repository={project.repository} length={12} /> : `${commits.length} source commits`}</dd>
410
+ <dt>commit</dt><dd className="truncate text-ink" title={pin ? pin.commit : commits.join(", ")}>{pin ? <CommitLink commit={pin.commit} repository={project.repository} length={12} /> : committed.length === 1 ? <CommitLink commit={committed[0]!} repository={project.repository} length={12} /> : committed.length > 1 ? `${committed.length} source commits` : commits.length > 0 ? "uncommitted" : "not stamped"}</dd>
346
411
  </dl>
347
412
 
348
413
  <div className="mt-4 flex flex-wrap gap-1.5" aria-label="Active plugins">
@@ -457,10 +522,15 @@ function PluginsList() {
457
522
  const runSteps = setupInfo.run?.steps.filter((step) => step.plugin === plugin.name) ?? [];
458
523
  const health = plugin.stepCount === 0 ? "unchecked" : healthFor(runSteps, declared.length, setupInfo);
459
524
  const outputs = [...new Set(runSteps.flatMap((step) => step.files))];
525
+ // What the plugin says of itself, when it is one the package ships.
526
+ // A plugin the manifest declares by its own `process` is not on the
527
+ // index, and the row says nothing rather than something made up.
528
+ const shipped = pluginByName(plugin.name);
460
529
  return (
461
530
  <details key={plugin.name} id={`plugin-${plugin.name}`} className="group scroll-mt-4 border-t border-line">
462
531
  <summary className="flex cursor-pointer list-none items-center gap-2.5 px-3 py-1.5 hover:bg-surface/60">
463
532
  {health === "healthy" ? <HealthDot health={health} /> : null}
533
+ <PluginIcon icon={pluginIcon(plugin.name, shipped?.category)} className="text-muted" />
464
534
  <span className="mono truncate text-ink" title={plugin.name}>{plugin.name}</span>
465
535
  <Runtime plugin={plugin} icon />
466
536
  {health === "healthy" ? null : <HealthBadge health={health} />}
@@ -477,6 +547,7 @@ function PluginsList() {
477
547
  <div className="grid gap-5 lg:grid-cols-[minmax(0,1.4fr)_minmax(14rem,1fr)]">
478
548
  <div><div className="label mb-2">last run</div><PipelineSteps steps={runSteps} /></div>
479
549
  <div className="space-y-4">
550
+ {shipped ? <div><div className="label mb-2">what it reads</div><p className="text-muted">{shipped.summary}</p><Link to={paths.plugin(plugin.name)} className="mono mt-1 inline-block text-accent hover:underline">options and source →</Link></div> : null}
480
551
  <div><div className="label mb-2">runtime</div><p className="mono"><Runtime plugin={plugin} /></p></div>
481
552
  <div><div className="label mb-2">used by</div>{plugin.projectIds.length > 0 ? <div className="flex flex-wrap gap-1.5">{plugin.projectIds.map((id) => <Link key={id} to={`${paths.settingsProjects()}#project-${id}`} className="chip border-line-strong hover:border-accent hover:text-accent">{projectNames.get(id) ?? id}</Link>)}</div> : <p className="mono text-muted">{plugin.stepCount > 0 ? "Estate-wide catalog" : "No pipeline step uses this plugin."}</p>}</div>
482
553
  {outputs.length > 0 ? <div><div className="label mb-2">outputs</div><ul className="mono space-y-1 text-muted">{outputs.map((output) => <li key={output} className="truncate"><FileLink path={output} /></li>)}</ul></div> : null}
@@ -581,6 +652,8 @@ const CAPABILITIES: Record<string, { title: string; summary: string }> = {
581
652
  "rust-domain": { title: "Rust domain model", summary: "Aggregates, entities, value objects and domain events." },
582
653
  "java-domain": { title: "Java domain model", summary: "Aggregates, entities, value objects and domain events." },
583
654
  "django-domain": { title: "Django data model", summary: "Models and their relationships." },
655
+ "laravel-domain": { title: "Laravel data model", summary: "Eloquent models, events and listeners, routes and controllers." },
656
+ "php-ddd": { title: "PHP DDD model", summary: "Bounded contexts laid out by module and hexagonal layer: aggregates, value objects, domain events, commands, queries, subscribers, Doctrine mappings." },
584
657
  openapi: { title: "HTTP API contract", summary: "Operations and messages declared by OpenAPI or Swagger." },
585
658
  wsdl: { title: "SOAP contract", summary: "Services, operations and messages declared by WSDL." },
586
659
  "http-clients": { title: "Outbound integrations", summary: "HTTP and SOAP calls, provider branches and code flows." },
@@ -712,7 +785,7 @@ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open:
712
785
  function configure(found: Discovery, commit: string, selectedSourcePath: string, projectId?: string) {
713
786
  setDiscovery(found);
714
787
  const plugins = found.detections.filter((item) => item.selected).map((item) => item.plugin);
715
- const domainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin));
788
+ const domainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain", "laravel-domain", "php-ddd"].includes(plugin));
716
789
  setDraft({ source, root: found.root, repository, ref, commit, sourcePath: selectedSourcePath, ...found.defaults, ...(projectId ? { id: projectId } : {}), contextName: found.defaults.group ? found.defaults.group.replace(/(^|-)([a-z])/g, (_, gap, letter) => `${gap ? " " : ""}${letter.toUpperCase()}`) : "", contextSummary: "", classification: "supporting", groupKind: domainModel ? "bounded-context" : "system", componentKind: domainModel ? "service" : "application", replaceStarter: Boolean(starter), plugins });
717
790
  setStage("configure");
718
791
  }
@@ -829,11 +902,15 @@ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open:
829
902
  const trialRunning = stage === "trial" && !finished;
830
903
  const confirmedDeployables = discovery?.deployables.filter((candidate) => candidate.confidence === "high") ?? [];
831
904
  const splitsDeployables = confirmedDeployables.length > 1 && draft?.component === discovery?.defaults.component;
905
+ const trialDiagnostics = trial?.diagnostics ?? [];
906
+ const trialActiveWarnings = trial
907
+ ? groupDiagnostics(trialDiagnostics).filter((group) => !group.suppressed).reduce((sum, group) => sum + group.count, 0)
908
+ : 0;
832
909
  const quality = trial && draft ? [
833
910
  { label: "Stable project identity", done: Boolean(draft.id.trim() && draft.name.trim()) },
834
911
  { label: "Architecture placement", done: Boolean(draft.group.trim() && (splitsDeployables || draft.component.trim())) },
835
912
  { label: "Catalog facts extracted", done: trial.facts.some((fact) => fact.count > 0) },
836
- { label: "Extraction without warnings", done: trial.warnings.length === 0 },
913
+ { label: "Extraction without active warnings", done: trialActiveWarnings === 0 },
837
914
  ] : [];
838
915
  const totalSteps = pipeline?.stepCount ?? plan?.steps.length ?? 0;
839
916
  const percent = totalSteps ? Math.round((completedSteps.length / totalSteps) * 100) : 0;
@@ -877,7 +954,7 @@ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open:
877
954
  {discovery.detections.length ? <div className="grid gap-2">{discovery.detections.map((item) => {
878
955
  const checked = draft.plugins.includes(item.plugin);
879
956
  const capability = CAPABILITIES[item.plugin] ?? { title: item.plugin, summary: "Catalog facts extracted from project files." };
880
- return <label key={item.plugin} className={`flex cursor-pointer items-start gap-3 rounded-control border px-3 py-3 transition-colors ${checked ? "border-accent bg-surface" : "border-line hover:bg-surface"}`}><input className="mt-1" type="checkbox" checked={checked} onChange={() => setDraft({ ...draft, plugins: checked ? draft.plugins.filter((name) => name !== item.plugin) : [...draft.plugins, item.plugin] })} /><span className="min-w-0 flex-1"><span className="flex flex-wrap items-center gap-2"><span className="font-medium text-ink">{capability.title}</span><span className={`chip ${item.confidence === "high" ? "status-verified" : "status-declared"}`}>{item.confidence} confidence</span>{item.candidates.length > 1 ? <span className="chip status-declared">{item.candidates.length} candidates</span> : null}</span><span className="mt-0.5 block text-muted">{capability.summary}</span><span className="mono mt-1 block truncate text-faint" title={item.evidence}>evidence · {item.evidence}</span><span className="mono mt-0.5 block text-faint">extractor · {item.plugin}</span></span></label>;
957
+ return <label key={item.plugin} className={`flex cursor-pointer items-start gap-3 rounded-control border px-3 py-3 transition-colors ${checked ? "border-accent bg-surface" : "border-line hover:bg-surface"}`}><input aria-label={`Toggle ${capability.title}`} className="mt-1" type="checkbox" checked={checked} onChange={() => setDraft({ ...draft, plugins: checked ? draft.plugins.filter((name) => name !== item.plugin) : [...draft.plugins, item.plugin] })} /><span className="min-w-0 flex-1"><span className="flex flex-wrap items-center gap-2"><span className="font-medium text-ink">{capability.title}</span><span className={`chip ${item.confidence === "high" ? "status-verified" : "status-declared"}`}>{item.confidence} confidence</span>{item.candidates.length > 1 ? <span className="chip status-declared">{item.candidates.length} candidates</span> : null}</span><span className="mt-0.5 block text-muted">{capability.summary}</span><span className="mono mt-1 block truncate text-faint" title={item.evidence}>evidence · {item.evidence}</span>{item.preview?.length ? <span className="mt-2 grid gap-1.5">{item.preview.slice(0, 3).map((preview) => <span key={preview.file} className="block rounded-control border border-line bg-canvas px-2 py-1.5"><span className="mono block truncate text-faint" title={preview.file}>{preview.file}</span><span className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-muted">{Object.entries(preview.fields).map(([field, value]) => <span key={field}><span className="text-faint">{field}</span> · <span className="text-ink">{value}</span></span>)}</span></span>)}{item.preview.length > 3 ? <span className="text-faint">+ {item.preview.length - 3} more recognized records</span> : null}</span> : null}<span className="mono mt-0.5 block text-faint">extractor · {item.plugin}</span></span></label>;
881
958
  })}</div> : <Empty>no supported project signals found</Empty>}
882
959
  </div>
883
960
  </div>
@@ -895,7 +972,7 @@ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open:
895
972
  {trial ? <section><div className="label mb-2">what Portolan found</div>{trial.facts.length ? <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">{trial.facts.map((fact) => <div key={fact.key} className="rounded-control border border-line bg-canvas px-3 py-2"><div className="tnum text-lg font-semibold text-ink">{fact.count}</div><div className="text-muted">{fact.label}</div></div>)}</div> : <div className="rounded-control border border-line bg-surface px-3 py-3 text-muted">Only component metadata was found. You can add APIs, domain models and stores later without re-adding the project.</div>}</section> : null}
896
973
  {trial ? <section><div className="label mb-2">catalog readiness</div><div className="grid gap-2 sm:grid-cols-2">{quality.map((item) => <div key={item.label} className="flex items-center gap-2 rounded-control border border-line px-3 py-2"><span className={`flex size-5 items-center justify-center rounded-full ${item.done ? "bg-verified text-canvas" : "bg-surface text-declared"}`}>{item.done ? <Check size={12} aria-hidden /> : <CircleAlert size={12} aria-hidden />}</span><span className={item.done ? "text-ink" : "text-muted"}>{item.label}</span></div>)}</div></section> : null}
897
974
  <section><div className="label mb-2">extractor results</div><div className="divide-y divide-line rounded-control border border-line">{(trial?.steps ?? completedSteps).map((step) => <div key={`${step.plugin}:${"ordinal" in step ? step.ordinal : "trial"}`} className="grid gap-1 px-3 py-2 sm:grid-cols-[1fr_auto_auto]"><span><span className="font-medium text-ink">{CAPABILITIES[step.plugin]?.title ?? step.plugin}</span><span className="mono ml-2 text-faint">{step.plugin}</span>{step.message ? <span className="mt-1 block text-unresolved">{step.message}</span> : null}</span><span className="mono text-muted">{step.fileCount} {plural(step.fileCount, "file")}</span><span className={`chip ${step.status === "failed" ? "status-unresolved" : "status-verified"}`}>{step.status === "failed" ? "failed" : "read"}</span></div>)}</div></section>
898
- {trial?.warnings.length ? <section><div className="label mb-2">warnings · {trial.warnings.length}</div><div className="space-y-2">{trial.warnings.map((warning, index) => <div key={`${warning.plugin}:${index}`} className="flex gap-2 rounded-control border border-declared px-3 py-2 text-muted"><CircleAlert size={15} className="mt-0.5 shrink-0 text-declared" /><span><span className="mono text-ink">{warning.plugin}</span> · {warning.message}</span></div>)}</div></section> : null}
975
+ {trialDiagnostics.length ? <section><div className="label mb-2">diagnostics · {trialDiagnostics.length}</div><WarningPanel diagnostics={trialDiagnostics} /></section> : null}
899
976
  {finished?.status === "failed" && logs.length ? <details><summary className="cursor-pointer text-muted">Generator log · {logs.length} lines</summary><pre className="mono mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-control bg-surface p-3 text-muted">{logs.map((event) => event.message).join("\n")}</pre></details> : null}
900
977
  {trial ? <div><div className="label mb-2">changes after apply</div><dl className="mono grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-muted"><dt>project</dt><dd className="text-ink">{plan.project.id}</dd>{plan.fetch ? <><dt>pin</dt><dd className="truncate text-ink"><CommitLink commit={plan.fetch.commit} repository={plan.fetch.repo} length={12} /></dd></> : null}<dt>source</dt><dd className="truncate text-ink">{plan.source}</dd><dt>pipeline</dt><dd className="text-ink">{plan.steps.length + (plan.fetch ? 1 : 0)} {plural(plan.steps.length + (plan.fetch ? 1 : 0), "extract step")}</dd><dt>fragments</dt><dd className="text-ink">{trial.generatedFiles} generated</dd></dl></div> : null}
901
978
  </div>
@@ -955,11 +1032,23 @@ const SETTINGS_LINKS = [
955
1032
  ["Projects", paths.settingsProjects()],
956
1033
  ["Pipeline", paths.settingsPipeline()],
957
1034
  ["Delivery", paths.settingsDelivery()],
1035
+ ["Recordings", paths.settingsRecordings()],
1036
+ ["Rules", paths.settingsRules()],
958
1037
  ["Integrations", paths.settingsIntegrations()],
959
1038
  ["Preferences", paths.settingsPreferences()],
960
1039
  ["About", paths.settingsAbout()],
961
1040
  ] as const;
962
1041
 
1042
+ function RecordingsRoute({ local }: { local: boolean }) {
1043
+ const setupInfo = useSetup();
1044
+ return (
1045
+ <section>
1046
+ <SectionTitle right={local ? "reviewed before writing" : "local mode required"}>Recordings</SectionTitle>
1047
+ <RecordingSettings local={local} projects={setupInfo.projects} />
1048
+ </section>
1049
+ );
1050
+ }
1051
+
963
1052
  function SettingsNav() {
964
1053
  return (
965
1054
  <nav className="mt-5 border-b border-line" aria-label="Settings sections">
@@ -1094,7 +1183,7 @@ function PipelineSettings() {
1094
1183
  ) : (
1095
1184
  <>
1096
1185
  <PluginsList />
1097
- <p className="mono mt-2 text-muted">WASM runs without network or environment access, and a generator without a filesystem. A host process runs with the permissions of the build; a plugin in the host is Portolan's own code doing what needs a socket, such as fetching another repository.</p>
1186
+ <p className="mono mt-2 text-muted">WASM runs without network or environment access, and a generator without a filesystem. A host process runs with the permissions of the build; a plugin in the host is Portolan's own code doing what needs a socket, such as fetching another repository. Every plugin the package ships, with what it reads and the options it takes, is on the <Link to={paths.plugins()} className="text-accent hover:underline">plugin reference</Link>.</p>
1098
1187
  </>
1099
1188
  )}
1100
1189
  </section>
@@ -1136,6 +1225,8 @@ function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolea
1136
1225
  <Route path="projects" element={<ProjectsSettings local={local} onAdd={onAdd} onRemove={onRemove} />} />
1137
1226
  <Route path="pipeline" element={<PipelineSettings />} />
1138
1227
  <Route path="delivery" element={<section><SectionTitle right={local ? "preview before writing" : "local mode required"}>Delivery presets</SectionTitle><DeliverySettings local={local} /></section>} />
1228
+ <Route path="recordings" element={<RecordingsRoute local={local} />} />
1229
+ <Route path="rules" element={<RulesSettings local={local} />} />
1139
1230
  <Route path="integrations" element={<IntegrationsSettings />} />
1140
1231
  <Route path="preferences" element={<PreferencesSettings />} />
1141
1232
  <Route path="about" element={<AboutSettings />} />
@@ -1148,6 +1239,88 @@ function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolea
1148
1239
  );
1149
1240
  }
1150
1241
 
1242
+ function RemoveProjectDialog({
1243
+ project,
1244
+ busy,
1245
+ onClose,
1246
+ onConfirm,
1247
+ }: {
1248
+ project: SetupProject | null;
1249
+ busy: boolean;
1250
+ onClose: () => void;
1251
+ onConfirm: () => void;
1252
+ }) {
1253
+ const name = project?.name ?? "project";
1254
+ return (
1255
+ <Modal
1256
+ open={project !== null}
1257
+ onClose={busy ? () => {} : onClose}
1258
+ label={`Remove ${name}`}
1259
+ width="min(520px,92vw)"
1260
+ >
1261
+ <div className="flex items-center gap-3 border-b border-line px-5 py-4">
1262
+ <m.span
1263
+ animate={busy ? { rotate: 360 } : { rotate: 0 }}
1264
+ transition={busy ? { duration: 0.9, ease: "linear", repeat: Infinity } : undefined}
1265
+ className="flex size-8 shrink-0 items-center justify-center rounded-full bg-unresolved/10 text-unresolved"
1266
+ >
1267
+ {busy ? <LoaderCircle size={17} aria-hidden /> : <Trash2 size={16} aria-hidden />}
1268
+ </m.span>
1269
+ <div className="min-w-0">
1270
+ <div className="font-semibold text-ink">{busy ? `Removing ${name}` : `Remove ${name}?`}</div>
1271
+ <div className="mono mt-0.5 truncate text-faint">{project?.id}</div>
1272
+ </div>
1273
+ </div>
1274
+
1275
+ <div className="min-h-40 p-5">
1276
+ <AnimatePresence mode="wait" initial={false}>
1277
+ {busy ? (
1278
+ <m.div key="removing" {...rise} aria-live="polite">
1279
+ <p className="font-medium text-ink">Updating project configuration…</p>
1280
+ <p className="mt-2 text-muted">
1281
+ Portolan is removing the project and its extraction steps. The source tree remains untouched.
1282
+ </p>
1283
+ <div
1284
+ role="progressbar"
1285
+ aria-label={`Removing ${name}`}
1286
+ className="mt-5 h-1.5 overflow-hidden rounded-full bg-surface"
1287
+ >
1288
+ <m.div
1289
+ className="h-full w-2/5 rounded-full bg-unresolved"
1290
+ initial={{ x: "-100%" }}
1291
+ animate={{ x: ["-100%", "250%"] }}
1292
+ transition={{ duration: 1.1, ease: "easeInOut", repeat: Infinity }}
1293
+ />
1294
+ </div>
1295
+ </m.div>
1296
+ ) : (
1297
+ <m.div key="confirm" {...rise}>
1298
+ <p className="text-muted">
1299
+ This removes the project, its extraction steps and its dedicated catalog configuration from <span className="mono text-ink">portolan.json</span>. Your source code stays untouched.
1300
+ </p>
1301
+ <p className="mt-3 text-muted">
1302
+ You can undo immediately. Generated documentation changes remain pending until you preview and apply them.
1303
+ </p>
1304
+ </m.div>
1305
+ )}
1306
+ </AnimatePresence>
1307
+ </div>
1308
+
1309
+ <div className="flex justify-end gap-2 border-t border-line px-5 py-4">
1310
+ <button type="button" className="tbtn" onClick={onClose} disabled={busy}>Cancel</button>
1311
+ <button type="button" className="product-primary product-danger min-w-36" onClick={onConfirm} disabled={busy}>
1312
+ <AnimatePresence mode="wait" initial={false}>
1313
+ <m.span key={busy ? "busy" : "ready"} {...rise} className="flex items-center justify-center gap-2">
1314
+ {busy ? <LoaderCircle size={15} aria-hidden /> : <Trash2 size={15} aria-hidden />}
1315
+ {busy ? "Removing…" : "Remove project"}
1316
+ </m.span>
1317
+ </AnimatePresence>
1318
+ </button>
1319
+ </div>
1320
+ </Modal>
1321
+ );
1322
+ }
1323
+
1151
1324
  export function Settings() {
1152
1325
  useDocumentTitle("Settings");
1153
1326
  const queryClient = useQueryClient();
@@ -1210,11 +1383,12 @@ export function Settings() {
1210
1383
  <SetupContext.Provider value={setup}>
1211
1384
  <SettingsContent local={local} onAdd={setWizardSource} onRemove={setRemoveTarget} onGenerate={() => void generate()} />
1212
1385
  <Wizard open={wizardSource !== null} initialSource={wizardSource ?? "local"} onClose={() => setWizardSource(null)} onAdded={setSetup} onRunStarted={(id) => { setRunId(id); setRunOpen(true); }} />
1213
- <Modal open={removeTarget !== null} onClose={removeBusy ? () => {} : () => setRemoveTarget(null)} label={`Remove ${removeTarget?.name ?? "project"}`} width="min(520px,92vw)">
1214
- <div className="border-b border-line px-5 py-4"><div className="font-semibold text-ink">Remove {removeTarget?.name}?</div></div>
1215
- <div className="p-5"><p className="text-muted">This removes the project, its extraction steps and its dedicated catalog configuration from <span className="mono text-ink">portolan.json</span>. Your source code stays untouched.</p><p className="mt-3 text-muted">You can undo immediately. Generated documentation changes remain pending until you preview and apply them.</p></div>
1216
- <div className="flex justify-end gap-2 border-t border-line px-5 py-4"><button type="button" className="tbtn" onClick={() => setRemoveTarget(null)} disabled={removeBusy}>Cancel</button><button type="button" className="product-primary" onClick={() => void confirmRemove()} disabled={removeBusy}>{removeBusy ? <LoaderCircle size={15} className="animate-spin" /> : <Trash2 size={15} />} Remove project</button></div>
1217
- </Modal>
1386
+ <RemoveProjectDialog
1387
+ project={removeTarget}
1388
+ busy={removeBusy}
1389
+ onClose={() => setRemoveTarget(null)}
1390
+ onConfirm={() => void confirmRemove()}
1391
+ />
1218
1392
  <RunDialog runId={runId} open={runOpen} onClose={() => setRunOpen(false)} onFinished={refresh} onApply={(preview) => void generate(preview)} />
1219
1393
  </SetupContext.Provider>
1220
1394
  );
@@ -1,4 +1,4 @@
1
- import { ArrowRight, BookOpen, CircleDot, GitBranch, ShieldCheck } from "lucide-react";
1
+ import { ArrowRight, BookOpen, CircleDot, GitBranch, Puzzle, ShieldCheck } from "lucide-react";
2
2
  import type { Variants } from "motion/react";
3
3
  import { Link } from "react-router";
4
4
 
@@ -94,6 +94,13 @@ export function AboutSettings() {
94
94
  >
95
95
  <div className="label mb-2">resources</div>
96
96
  <div className="divide-y divide-line">
97
+ <Link
98
+ to={paths.plugins()}
99
+ className="flex items-center gap-2 py-2.5 text-muted transition-colors hover:text-ink"
100
+ >
101
+ <Puzzle size={15} aria-hidden className="shrink-0" />
102
+ Plugin reference
103
+ </Link>
97
104
  <a
98
105
  href={PRODUCT_README}
99
106
  target="_blank"