@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
@@ -20,6 +20,7 @@ import {
20
20
  TABLE_ROLES,
21
21
  aggregateBlocks,
22
22
  allAggregates,
23
+ allDeployments,
23
24
  allExternals,
24
25
  allModules,
25
26
  allRepos,
@@ -165,6 +166,20 @@ export function validateCatalog(catalog: Catalog): Catalog {
165
166
  ),
166
167
  );
167
168
  const storeIds = new Set(allStores(catalog).map((store) => store.id));
169
+ // `<service>|<aggregate id>/<operation id>`: what a `call` step into a
170
+ // service may name - the use case it runs, when the flow crosses a
171
+ // boundary the transport does not draw (an in-process command bus).
172
+ const operationRefs = new Set(
173
+ catalog.contexts.flatMap((context) =>
174
+ context.services.flatMap((service) =>
175
+ service.aggregates.flatMap((aggregate) =>
176
+ aggregate.operations.map(
177
+ (operation) => `${service.id}|${aggregate.id}/${operation.id}`,
178
+ ),
179
+ ),
180
+ ),
181
+ ),
182
+ );
168
183
 
169
184
  assertUniqueSlugs(
170
185
  catalog.contexts.map((c) => c.id),
@@ -253,6 +268,27 @@ export function validateCatalog(catalog: Catalog): Catalog {
253
268
  }
254
269
  technologies.add(technology);
255
270
  }
271
+ // Hosts and dials are names, and a name is checked the way a handle
272
+ // is: not blank, not listed twice. Two services behind one Ingress
273
+ // host is one host on two pages, which is what the manifests say.
274
+ for (const field of ["hosts", "dials"] as const) {
275
+ const names = new Set<string>();
276
+ for (const name of service[field] ?? []) {
277
+ if (!name.trim()) {
278
+ fail(
279
+ `service "${service.id}" has a ${field.slice(0, -1)} with no name`,
280
+ `service ${service.id}`,
281
+ );
282
+ }
283
+ if (names.has(name)) {
284
+ fail(
285
+ `service "${service.id}" names ${field.slice(0, -1)} "${name}" twice`,
286
+ `service ${service.id}`,
287
+ );
288
+ }
289
+ names.add(name);
290
+ }
291
+ }
256
292
  for (const call of service.consumes) rpcIds.add(call.id);
257
293
  for (const provided of service.provides) {
258
294
  for (const method of provided.methods) {
@@ -366,6 +402,14 @@ export function validateCatalog(catalog: Catalog): Catalog {
366
402
  );
367
403
  }
368
404
  }
405
+ for (const field of operation.fields ?? []) {
406
+ if (field.ref !== undefined && !(field.ref in catalog.defs)) {
407
+ fail(
408
+ `field "${field.name}" of operation "${operation.id}" of aggregate "${aggregate.id}" references unknown def "${field.ref}"`,
409
+ `aggregate ${aggregate.id} / operation ${operation.id} / field ${field.name}`,
410
+ );
411
+ }
412
+ }
369
413
  }
370
414
  validateBlocks(catalog, aggregate);
371
415
  assertUniqueSlugs(
@@ -622,16 +666,47 @@ export function validateCatalog(catalog: Catalog): Catalog {
622
666
  eventIds.has(step.ref) ||
623
667
  rpcIds.has(step.ref) ||
624
668
  (step.kind === "rpc" &&
625
- providedRpcRefs.has(`${step.to}|${step.ref}`));
669
+ providedRpcRefs.has(`${step.to}|${step.ref}`)) ||
670
+ (step.kind === "call" &&
671
+ operationRefs.has(`${step.to}|${step.ref}`));
626
672
  if (!resolves) {
627
673
  fail(
628
- `flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event, an RpcCall nor a method provided by "${step.to}", and status is "${step.status}" rather than "unresolved"`,
674
+ `flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event, an RpcCall, a method provided by "${step.to}" nor an operation of "${step.to}", and status is "${step.status}" rather than "unresolved"`,
629
675
  `flow ${flow.id} / step ${step.id}`,
630
676
  );
631
677
  }
632
678
  }
633
679
  }
634
680
 
681
+ // An example names the steps it showed by id; one that names a step
682
+ // the flow does not have is a recording of some other flow, or of this
683
+ // one before it changed, and either way it would be shown next to the
684
+ // wrong step.
685
+ const exampleIds = new Set<string>();
686
+ for (const example of flow.examples ?? []) {
687
+ if (!example.id || !example.recording || !example.traceId) {
688
+ fail(
689
+ `flow "${flow.slug}" has an example with no id, recording or trace id`,
690
+ `flow ${flow.id}`,
691
+ );
692
+ }
693
+ if (exampleIds.has(example.id)) {
694
+ fail(
695
+ `flow "${flow.slug}" carries example "${example.id}" twice`,
696
+ `flow ${flow.id}`,
697
+ );
698
+ }
699
+ exampleIds.add(example.id);
700
+ for (const shown of example.steps) {
701
+ if (!stepById.has(shown.step)) {
702
+ fail(
703
+ `flow "${flow.slug}" example "${example.id}" shows unknown step "${shown.step}"`,
704
+ `flow ${flow.id}`,
705
+ );
706
+ }
707
+ }
708
+ }
709
+
635
710
  for (const step of steps) {
636
711
  if (step.kind !== "response") {
637
712
  if (step.replyTo !== undefined) {
@@ -670,10 +745,37 @@ export function validateCatalog(catalog: Catalog): Catalog {
670
745
  validateAdrs(catalog, eventIds);
671
746
  validateTerms(catalog);
672
747
  validateRepos(catalog);
748
+ validateDeployments(catalog);
673
749
 
674
750
  return catalog;
675
751
  }
676
752
 
753
+ /**
754
+ * A deployment names an Application, and names it once.
755
+ *
756
+ * What is not checked is deliberate, and the same omission `validateRepos`
757
+ * makes: a deployment of a repository no service claims is NOT an error. The
758
+ * snapshot lists what the control plane manages, and an Application for a
759
+ * service nobody has read yet, or for something that is not a service at
760
+ * all - a monitoring stack, an ingress controller - is the ordinary case.
761
+ * It simply matches nothing on a service page. Nor is a row with an empty
762
+ * environment or repository: the snapshot says what the deployer said, and
763
+ * a build must not go red because one Application was written oddly.
764
+ */
765
+ function validateDeployments(catalog: Catalog): void {
766
+ const seen = new Set<string>();
767
+
768
+ for (const deployment of allDeployments(catalog)) {
769
+ const where = `deployment ${deployment.id || "?"}`;
770
+ if (!deployment.id) fail("a deployment has no id", where);
771
+ if (!deployment.name) fail(`deployment "${deployment.id}" names no application`, where);
772
+ if (seen.has(deployment.id)) {
773
+ fail(`deployment "${deployment.id}" is listed twice in one catalog`, where);
774
+ }
775
+ seen.add(deployment.id);
776
+ }
777
+ }
778
+
677
779
  /**
678
780
  * A module reference may only point at a module that exists.
679
781
  *
@@ -1352,6 +1454,15 @@ function validateBlocks(catalog: Catalog, aggregate: Aggregate): void {
1352
1454
 
1353
1455
  validateEnums(aggregate);
1354
1456
 
1457
+ if (aggregate.kind !== undefined && aggregate.kind !== "model-group") {
1458
+ fail(`aggregate "${aggregate.id}" has an unknown kind`, `aggregate ${aggregate.id}`);
1459
+ }
1460
+ if (aggregate.kind === "model-group") {
1461
+ if (aggregate.root !== "" || aggregate.lifecycle) {
1462
+ fail(`model group "${aggregate.id}" cannot declare an aggregate root or lifecycle`, `aggregate ${aggregate.id}`);
1463
+ }
1464
+ return;
1465
+ }
1355
1466
  if (!aggregate.root) {
1356
1467
  fail(
1357
1468
  `aggregate "${aggregate.id}" names no root entity`,
@@ -86,6 +86,22 @@ describe("validateCatalog", () => {
86
86
  expect(() => validateCatalog(bad)).toThrow(/expected 1.1 or 1.2/);
87
87
  });
88
88
 
89
+ it("rejects an example that shows a step the flow does not have, and one carried twice", () => {
90
+ const bad = clone();
91
+ const checkout = bad.flows.find((f) => f.slug === "checkout") as Flow;
92
+ const first = walkSteps(checkout.steps)[0];
93
+ if (!first) throw new Error("fixture has no steps");
94
+ const example = { id: "t.jsonl#1", recording: "t.jsonl", traceId: "1", durationMs: 1, steps: [{ step: first.id, durationMs: 1 }] };
95
+ checkout.examples = [example];
96
+ expect(() => validateCatalog(bad)).not.toThrow();
97
+
98
+ checkout.examples = [{ ...example, steps: [{ step: "nope", durationMs: 1 }] }];
99
+ expect(() => validateCatalog(bad)).toThrowError(/flow "checkout" example "t.jsonl#1" shows unknown step "nope"/);
100
+
101
+ checkout.examples = [example, example];
102
+ expect(() => validateCatalog(bad)).toThrowError(/carries example "t.jsonl#1" twice/);
103
+ });
104
+
89
105
  it("rejects a step whose participant was never declared, naming flow and step", () => {
90
106
  const bad = clone();
91
107
  const checkout = bad.flows.find((f) => f.slug === "checkout") as Flow;
@@ -105,12 +121,35 @@ describe("validateCatalog", () => {
105
121
  if (!step) throw new Error("fixture has no steps");
106
122
  step.ref = "shop.oms.order.NoSuchEvent";
107
123
  expect(() => validateCatalog(bad)).toThrowError(
108
- /resolves to neither an Event, an RpcCall nor a method/,
124
+ /resolves to neither an Event, an RpcCall, a method/,
109
125
  );
110
126
  step.status = "unresolved";
111
127
  expect(() => validateCatalog(bad)).not.toThrow();
112
128
  });
113
129
 
130
+ // A flow that crosses a boundary the transport does not draw - a command
131
+ // handed to another context over an in-process bus - names the use case
132
+ // it runs, and only a `call` step into that use case's service may.
133
+ it("accepts a call step that references an operation of the service it enters", () => {
134
+ const good = clone();
135
+ const service = good.contexts[0]?.services[0];
136
+ const aggregate = service?.aggregates[0];
137
+ const operation = aggregate?.operations[0];
138
+ const flow = good.flows[0] as Flow;
139
+ const step = walkSteps(flow.steps)[0];
140
+ if (!service || !aggregate || !operation || !step) throw new Error("nothing to reference");
141
+ if (!flow.participants.some((p) => p.id === service.id)) {
142
+ flow.participants.push({ id: service.id, kind: "service", context: good.contexts[0]!.id });
143
+ }
144
+ step.kind = "call";
145
+ step.to = service.id;
146
+ step.ref = `${aggregate.id}/${operation.id}`;
147
+ expect(() => validateCatalog(good)).not.toThrow();
148
+
149
+ step.kind = "rpc";
150
+ expect(() => validateCatalog(good)).toThrowError(/nor an operation of/);
151
+ });
152
+
114
153
  it("rejects a field ref that is not a defs key", () => {
115
154
  const bad = clone();
116
155
  const field =
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { KindIcon } from "../components/kind";
8
8
  import type { Kind } from "../lib/kinds";
9
- import { activeCatalogProfile, catalog } from "../data";
9
+ import { activeCatalogDocs, catalog } from "../data";
10
10
  import type { ChatPageContext } from "./page-context";
11
11
  import { contextQuestions } from "./page-context";
12
12
 
@@ -36,8 +36,6 @@ function examples(): Example[] {
36
36
  return out;
37
37
  }
38
38
 
39
- const base = import.meta.env.BASE_URL;
40
-
41
39
  export function Starter({
42
40
  onAsk,
43
41
  page,
@@ -51,10 +49,6 @@ export function Starter({
51
49
  question,
52
50
  }))
53
51
  : examples();
54
- const profileSuffix =
55
- activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
56
- const docsRoot = `${base}docs/${profileSuffix}`;
57
- const indexHref = profileSuffix ? `${docsRoot}llms.txt` : `${base}llms.txt`;
58
52
 
59
53
  return (
60
54
  <div className="pt-1">
@@ -79,10 +73,10 @@ export function Starter({
79
73
  <div className="mono truncate text-muted" title={page.id}>{page.id}</div>
80
74
  </div>
81
75
  </div>
82
- <div className="mono mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-line pt-2 text-muted">
76
+ {activeCatalogDocs ? <div className="mono mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-line pt-2 text-muted">
83
77
  <span className="text-faint">model context</span>
84
78
  <a
85
- href={indexHref}
79
+ href={activeCatalogDocs.index}
86
80
  target="_blank"
87
81
  rel="noreferrer"
88
82
  className="rounded-control text-accent hover:underline"
@@ -91,7 +85,7 @@ export function Starter({
91
85
  </a>
92
86
  {page.docPath ? (
93
87
  <a
94
- href={`${docsRoot}${page.docPath}`}
88
+ href={`${activeCatalogDocs.pages}${page.docPath}`}
95
89
  target="_blank"
96
90
  rel="noreferrer"
97
91
  className="max-w-full truncate rounded-control text-accent hover:underline"
@@ -100,7 +94,7 @@ export function Starter({
100
94
  current catalog page
101
95
  </a>
102
96
  ) : null}
103
- </div>
97
+ </div> : null}
104
98
  </div>
105
99
  ) : null}
106
100
  <div className="label mt-5 mb-1">try one</div>
@@ -0,0 +1,27 @@
1
+ import { afterEach, expect, it, vi } from "vitest";
2
+ import { catalogDocs } from "../catalog-docs";
3
+
4
+ afterEach(() => {
5
+ vi.unstubAllGlobals();
6
+ vi.doUnmock("../data");
7
+ vi.resetModules();
8
+ });
9
+
10
+ it.each([
11
+ { id: "default", generate: [{ plugin: "markdown", out: "docs" }], index: "/portolan/llms.txt", pages: "/portolan/docs/" },
12
+ { id: "example", generate: [
13
+ { plugin: "markdown", catalog: "portolan", out: "docs" },
14
+ { plugin: "markdown", catalog: "example", out: "docs/example" },
15
+ ], index: "/portolan/docs/example/llms.txt", pages: "/portolan/docs/example/" },
16
+ ])("loads the index and catalog pages for $id", async ({ id, generate, index, pages }) => {
17
+ vi.doMock("../data", () => ({ activeCatalogDocs: catalogDocs({ generate }, id, "/portolan/") }));
18
+ const fetcher = vi.fn().mockResolvedValue(new Response("# Catalog documentation"));
19
+ vi.stubGlobal("fetch", fetcher);
20
+ const tools = await import("./tools");
21
+
22
+ expect(await tools.loadIndex()).toBe("# Catalog documentation");
23
+ expect(fetcher).toHaveBeenLastCalledWith(index);
24
+ fetcher.mockResolvedValue(new Response("# Auth service"));
25
+ expect(await tools.readPage("docs/auth/README.md")).toBe("# Auth service");
26
+ expect(fetcher).toHaveBeenLastCalledWith(`${pages}auth/README.md`);
27
+ });
package/src/chat/tools.ts CHANGED
@@ -6,19 +6,14 @@
6
6
 
7
7
  import { jsonSchema, tool } from "ai";
8
8
  import { clipPage, pagePath, TOOL_SPECS } from "./prompt";
9
- import { activeCatalogProfile } from "../data";
10
-
11
- const base = import.meta.env.BASE_URL;
12
- const profileSuffix =
13
- activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
14
- const docsBase = `${base}docs/${profileSuffix}`;
15
- const indexUrl = profileSuffix ? `${docsBase}llms.txt` : `${base}llms.txt`;
9
+ import { activeCatalogDocs } from "../data";
16
10
 
17
11
  /** One page of the generated docs, as the model gets it. */
18
12
  export async function readPage(raw: unknown): Promise<string> {
19
13
  const path = pagePath(raw);
20
14
  if (!path) return "That is not a page of the catalog. Use a path from the index.";
21
- const response = await fetch(`${docsBase}${path.slice("docs/".length)}`);
15
+ if (!activeCatalogDocs) return "No documentation is configured for this catalog.";
16
+ const response = await fetch(`${activeCatalogDocs.pages}${path.slice("docs/".length)}`);
22
17
  if (!response.ok) return `No page at ${path}. Use a path from the index.`;
23
18
  return clipPage(await response.text());
24
19
  }
@@ -26,7 +21,8 @@ export async function readPage(raw: unknown): Promise<string> {
26
21
  /** llms.txt, once per session. */
27
22
  let indexPromise: Promise<string> | null = null;
28
23
  export function loadIndex(): Promise<string> {
29
- indexPromise ??= fetch(indexUrl).then((response) => {
24
+ if (!activeCatalogDocs) return Promise.reject(new Error("No documentation is configured for this catalog."));
25
+ indexPromise ??= fetch(activeCatalogDocs.index).then((response) => {
30
26
  if (!response.ok) {
31
27
  indexPromise = null;
32
28
  throw new Error("the catalog index (llms.txt) could not be read");
@@ -6,23 +6,25 @@ import { absoluteTime, plural, relativeTime } from "../lib/format";
6
6
  /**
7
7
  * Where the catalog on this page came from, and when.
8
8
  *
9
- * The header says one date and one commit, and both are summaries of a corpus:
10
- * the date is the OLDEST of the sources, because a merged catalog is exactly
11
- * as fresh as its stalest part, and the commit is a count whenever the sources
12
- * do not agree on one. A summary of many numbers is the right thing to put in
13
- * a header and the wrong thing to leave a reader with - "6 sources · 15 hours
9
+ * A source is dated by the commit that last changed it, read off the
10
+ * checkout's history rather than out of the file (portolan.0010). The header
11
+ * says one date and one commit, and both are summaries of a corpus: the date
12
+ * is the OLDEST of the sources, because a merged catalog is exactly as fresh
13
+ * as its stalest part, and the commit is a count whenever the sources do not
14
+ * agree on one. A summary of many numbers is the right thing to put in a
15
+ * header and the wrong thing to leave a reader with - "6 sources · 15 hours
14
16
  * ago" answers "is this current" and refuses "which part is not", which is the
15
17
  * question anyone who did not like the first answer asks next.
16
18
  *
17
19
  * So the stamp opens. Behind it is one row per commit, newest first, and the
18
20
  * row the header is quoting says so. Rows are per COMMIT and not per file
19
- * because twenty-eight fragments written by one commit are one source of
21
+ * because twenty-eight fragments changed by one commit are one source of
20
22
  * facts, and it is the same count the header prints.
21
23
  */
22
24
  export function CatalogStamp() {
23
25
  const groups = byCommit(catalogSources);
24
26
  const authored = catalogSources.filter((source) => !source.commit);
25
- const summary = `catalog generated ${absoluteTime(catalog.generatedAt)} from commit ${catalog.commit}`;
27
+ const summary = `catalog last changed ${absoluteTime(catalog.generatedAt)}, ${catalog.commit}`;
26
28
 
27
29
  return (
28
30
  <Popover className="ml-auto">
@@ -43,7 +45,7 @@ export function CatalogStamp() {
43
45
  >
44
46
  <div className="label mb-1.5 px-1">
45
47
  {groups.length} {plural(groups.length, "source")}, by the commit each
46
- was generated from
48
+ last changed in
47
49
  </div>
48
50
  <dl className="mono grid grid-cols-[auto_1fr_auto] items-baseline gap-x-3 gap-y-1 px-1">
49
51
  {groups.map((group, i) => (
@@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
2
2
  import { MemoryRouter } from "react-router";
3
3
  import { describe, expect, it } from "vitest";
4
4
  import type { Channel } from "../catalog";
5
- import { ChannelRowsContent } from "./ChannelRows";
5
+ import { ChannelRowsContent, countDirections, rowsOf } from "./ChannelRows";
6
6
 
7
7
  const kafka: Channel = {
8
8
  address: "orders/created",
@@ -18,6 +18,18 @@ const nats: Channel = {
18
18
  messages: [],
19
19
  };
20
20
 
21
+ /** A work queue the way extract-laravel and extract-celery write one: a job put on and worked. */
22
+ const queue: Channel = {
23
+ address: "default",
24
+ kind: "job",
25
+ title: "Queue · default",
26
+ messages: [
27
+ { name: "App\\Jobs\\IndexOrder", title: "IndexOrder", doc: "Puts the order in the index.", direction: "send" },
28
+ { name: "App\\Jobs\\IndexOrder", title: "IndexOrder", doc: "Worked by `IndexOrder::handle`.", direction: "receive" },
29
+ { name: "App\\Jobs\\ImportBatch", title: "ImportBatch", doc: "Worked by `ImportBatch::handle`.", direction: "receive" },
30
+ ],
31
+ };
32
+
21
33
  describe("ChannelRows Kafka UI integration", () => {
22
34
  it("links only Kafka cards to their exact topic when configured", () => {
23
35
  const markup = renderToStaticMarkup(
@@ -50,5 +62,68 @@ describe("ChannelRows Kafka UI integration", () => {
50
62
 
51
63
  expect(markup).not.toContain("view in Kafka UI");
52
64
  });
65
+ });
66
+
67
+ describe("ChannelRows direction", () => {
68
+ it("draws each direction as its own chip", () => {
69
+ const markup = renderToStaticMarkup(
70
+ <MemoryRouter>
71
+ <ChannelRowsContent channels={[queue]} service="shop.app" kafkaUi="" />
72
+ </MemoryRouter>,
73
+ );
74
+ expect(markup.match(/data-direction="send"/g)).toHaveLength(1);
75
+ expect(markup.match(/data-direction="receive"/g)).toHaveLength(2);
76
+ expect(markup).toContain('class="chip dir-send"');
77
+ expect(markup).toContain('class="chip dir-receive"');
78
+ });
79
+
80
+ it("keeps one direction when filtered, and says so when a channel has none of it", () => {
81
+ const rows = rowsOf(queue.messages, "send", false);
82
+ expect(rows.map((r) => `${r.directions[0]} ${r.message.title}`)).toEqual(["send IndexOrder"]);
83
+
84
+ const markup = renderToStaticMarkup(
85
+ <MemoryRouter>
86
+ <ChannelRowsContent channels={[queue]} service="shop.app" kafkaUi="" filter="send" />
87
+ </MemoryRouter>,
88
+ );
89
+ expect(markup).not.toContain("ImportBatch");
90
+ expect(markup).not.toContain("nothing sent");
53
91
 
92
+ const empty = renderToStaticMarkup(
93
+ <MemoryRouter>
94
+ <ChannelRowsContent
95
+ channels={[{ ...queue, messages: queue.messages.filter((m) => m.direction === "receive") }]}
96
+ service="shop.app"
97
+ kafkaUi=""
98
+ filter="send"
99
+ />
100
+ </MemoryRouter>,
101
+ );
102
+ expect(empty).toContain("nothing sent");
103
+ });
104
+
105
+ it("folds the send and receive of one message into a row wearing both chips", () => {
106
+ const rows = rowsOf(queue.messages, "all", true);
107
+ expect(rows.map((r) => `${r.message.title}: ${r.directions.join("+")}`)).toEqual([
108
+ "IndexOrder: send+receive",
109
+ "ImportBatch: receive",
110
+ ]);
111
+ // The sent message carries the sender's doc, whichever came first.
112
+ const reversed = rowsOf([...queue.messages].reverse(), "all", true);
113
+ expect(reversed[1]?.message.doc).toBe("Puts the order in the index.");
114
+ expect(reversed[1]?.directions).toEqual(["send", "receive"]);
115
+
116
+ const markup = renderToStaticMarkup(
117
+ <MemoryRouter>
118
+ <ChannelRowsContent channels={[queue]} service="shop.app" kafkaUi="" grouped />
119
+ </MemoryRouter>,
120
+ );
121
+ // Two rows for three messages, three chips between them.
122
+ expect(markup.match(/<li /g)).toHaveLength(2);
123
+ expect(markup.match(/data-direction=/g)).toHaveLength(3);
124
+ });
125
+
126
+ it("counts what travels each way across every channel", () => {
127
+ expect(countDirections([queue, kafka])).toEqual({ send: 1, receive: 2 });
128
+ });
54
129
  });