@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
package/src/lib/derive.ts CHANGED
@@ -2,12 +2,14 @@
2
2
  // so every number a Phase 2 page shows can be asserted in a test.
3
3
 
4
4
  import type {
5
+ Aggregate,
5
6
  Block,
6
7
  BoundedContext,
7
8
  Catalog,
8
9
  Event,
9
10
  Field,
10
11
  Flow,
12
+ Operation,
11
13
  Service,
12
14
  Step,
13
15
  } from "../catalog";
@@ -185,6 +187,38 @@ export interface StepRef {
185
187
  number: number;
186
188
  }
187
189
 
190
+ /**
191
+ * The flows in which an operation runs: one whose step carries the
192
+ * operation's own ref, `<aggregate id>/<operation id>` - a call across a
193
+ * boundary - or whose incoming rpc step is a method the operation says
194
+ * exposes it. Each flow once, at its first such step.
195
+ */
196
+ export function flowsRunning(
197
+ catalog: Catalog,
198
+ service: Service,
199
+ aggregate: Aggregate,
200
+ operation: Operation,
201
+ ): StepRef[] {
202
+ const own = `${aggregate.id}/${operation.id}`;
203
+ const methods = new Set(
204
+ (operation.exposedBy ?? []).flatMap((method) =>
205
+ service.provides.map((provided) => `${provided.id}/${method}`),
206
+ ),
207
+ );
208
+ const out: StepRef[] = [];
209
+ for (const flow of catalog.flows) {
210
+ const steps = walkSteps(flow.steps);
211
+ const i = steps.findIndex(
212
+ (step) =>
213
+ step.ref !== undefined &&
214
+ (step.ref === own || (step.kind === "rpc" && methods.has(step.ref))),
215
+ );
216
+ const hit = steps[i];
217
+ if (hit) out.push({ flow, stepId: hit.id, number: i + 1 });
218
+ }
219
+ return out;
220
+ }
221
+
188
222
  /** Every step across every flow that references an event, with its 1-based number. */
189
223
  export function stepsReferencing(catalog: Catalog, eventId: string): StepRef[] {
190
224
  const out: StepRef[] = [];
@@ -404,29 +438,6 @@ export function usesOfDef(catalog: Catalog, defId: string): DefUse {
404
438
  // Problems
405
439
  // ---------------------------------------------------------------------------
406
440
 
407
- /**
408
- * One edge in the estate that does not land. Two things can be wrong, and they
409
- * are wrong in the same way: a service calls an rpc whose provider is not in
410
- * the catalog, or an event names a consumer that is not either. Both mean the
411
- * chart draws an arrow into open water.
412
- */
413
- export type ProblemKind =
414
- | "rpc"
415
- | "consumer"
416
- | "cross-service-fk"
417
- | "cross-service-lineage"
418
- | "shared-store"
419
- | "persistence-drift"
420
- | "column-type"
421
- | "outbox-payload"
422
- | "proto-missing"
423
- | "proto-drift"
424
- | "shared-channel"
425
- | "channel-undeclared"
426
- | "channel-unpublished"
427
- | "message-encoding"
428
- | "subscription-unresolved";
429
-
430
441
  /**
431
442
  * How wrong a problem is. Two values, not five: an edge either lands somewhere
432
443
  * the catalog knows about or it does not (an error), or the catalog holds two
@@ -435,26 +446,27 @@ export type ProblemKind =
435
446
  */
436
447
  export type Severity = "error" | "warning";
437
448
 
449
+ /**
450
+ * One row of the Problems page: what a rule found, from the near end the
451
+ * reader can see. Every rule is CEL over one subject (rules/builtin.json for
452
+ * the ones the package ships, `problemRules` in the manifest for the estate's
453
+ * own), and `rule` says which; the subject says where `id` leads.
454
+ */
438
455
  export interface Problem {
439
- kind: ProblemKind;
456
+ rule: string;
440
457
  severity: Severity;
441
458
  /** The context that owns the end we can see. */
442
459
  context: string;
443
460
  /** The service that owns the end we can see. */
444
461
  service: string;
445
- /** The call id or the event id - what is on the near end of the edge. */
462
+ /** What is on the near end of the edge: the subject's id. */
446
463
  id: string;
447
- /** The name that resolves to nothing. */
464
+ /** The far end, when the rule names one. */
448
465
  peer: string;
449
466
  note: string | undefined;
450
467
  source: string | undefined;
451
468
  }
452
469
 
453
- /**
454
- * Every unresolved edge, contexts in catalog order. Nothing is scored and
455
- * nothing is ranked: an unresolved consumer is exactly as broken as an
456
- * unresolved call, and sorting them by badness would invent a fact.
457
- */
458
470
  /**
459
471
  * How many edges there were to resolve at all - every rpc call a service makes
460
472
  * and every consumer an event names, whatever their status.
@@ -477,41 +489,3 @@ export function edgeCount(catalog: Catalog): number {
477
489
  return n;
478
490
  }
479
491
 
480
- export function problems(catalog: Catalog): Problem[] {
481
- const out: Problem[] = [];
482
- for (const context of catalog.contexts) {
483
- for (const service of context.services) {
484
- for (const call of service.consumes) {
485
- if (call.status !== "unresolved") continue;
486
- out.push({
487
- kind: "rpc",
488
- severity: "error",
489
- context: context.id,
490
- service: service.id,
491
- id: call.id,
492
- peer: call.peer,
493
- note: call.note,
494
- source: call.source,
495
- });
496
- }
497
- for (const aggregate of service.aggregates) {
498
- for (const event of aggregate.events) {
499
- for (const consumer of event.consumers) {
500
- if (consumer.status !== "unresolved") continue;
501
- out.push({
502
- kind: "consumer",
503
- severity: "error",
504
- context: context.id,
505
- service: service.id,
506
- id: event.id,
507
- peer: consumer.service,
508
- note: consumer.note,
509
- source: undefined,
510
- });
511
- }
512
- }
513
- }
514
- }
515
- }
516
- return out;
517
- }
@@ -0,0 +1,9 @@
1
+ export interface DjangoAggregateCandidates {
2
+ app: string;
3
+ models: Array<{ name: string; path: string; line: number }>;
4
+ }
5
+
6
+ /** Evidence emitted by extract-django, carried through the warning protocol. */
7
+ export function djangoAggregateCandidates(message: string): DjangoAggregateCandidates | null;
8
+
9
+ export function djangoAggregateMessage(message: string): string;
@@ -0,0 +1,36 @@
1
+ // Plain JavaScript on purpose: the CLI's `init` runs this from node_modules,
2
+ // where Node strips no types, so the one parser the host and the browser
3
+ // share cannot be a .ts file. Types live in django-aggregates.d.mts.
4
+
5
+ const MARKER = "; aggregate candidates: ";
6
+
7
+ /**
8
+ * Evidence emitted by extract-django, carried through the warning protocol.
9
+ *
10
+ * @param {string} message
11
+ * @returns {import("./django-aggregates.d.mts").DjangoAggregateCandidates | null}
12
+ */
13
+ export function djangoAggregateCandidates(message) {
14
+ const start = message.lastIndexOf(MARKER);
15
+ if (start < 0) return null;
16
+ try {
17
+ const value = JSON.parse(message.slice(start + MARKER.length));
18
+ if (!value || typeof value.app !== "string" || !/^[\p{L}_][\p{L}\p{N}_]*(?:\.[\p{L}_][\p{L}\p{N}_]*)*$/u.test(value.app)
19
+ || !Array.isArray(value.models) || !value.models.length) return null;
20
+ const models = [];
21
+ for (const model of value.models) {
22
+ if (!model || typeof model.name !== "string" || !/^[\p{L}_][\p{L}\p{N}_]*$/u.test(model.name)
23
+ || typeof model.path !== "string" || !model.path || !Number.isSafeInteger(model.line) || model.line < 1) return null;
24
+ models.push({ name: model.name, path: model.path, line: model.line });
25
+ }
26
+ // The extractor's option accepts a class name, so duplicate names cannot
27
+ // be resolved safely by this picker.
28
+ if (new Set(models.map((model) => model.name)).size !== models.length) return null;
29
+ return { app: value.app, models };
30
+ } catch { return null; }
31
+ }
32
+
33
+ /** @param {string} message */
34
+ export function djangoAggregateMessage(message) {
35
+ return djangoAggregateCandidates(message) ? message.slice(0, message.lastIndexOf(MARKER)) : message;
36
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { djangoAggregateCandidates } from "./django-aggregates";
3
+ import { warningDiagnostic } from "./warnings";
4
+ import { publicSetupFrom } from "./setup-info";
5
+
6
+ describe("Django aggregate evidence", () => {
7
+ const candidates = { app: "billing.records", models: Array.from({ length: 29 }, (_, index) => ({ name: `Record${index}`, path: "source/billing/records/models.py", line: index + 1 })) };
8
+ const message = `source/billing/records: no model called Records, and 29 models to choose from: name the root in the aggregates option; aggregate candidates: ${JSON.stringify(candidates)}`;
9
+
10
+ it("retains all candidates beyond the public warning text limit", () => {
11
+ const warning = warningDiagnostic({ plugin: "django-domain", message });
12
+ expect(warning.aggregateCandidates).toEqual(candidates);
13
+ expect(warning.message).not.toContain("aggregate candidates:");
14
+ const setup = publicSetupFrom({ sources: [], extract: [{ plugin: "django-domain", in: "source", out: "data" }] }, {
15
+ version: 1, manifestSha256: "current", mode: "write", status: "ok", startedAt: "2026-09-10T00:00:00Z", finishedAt: "2026-09-10T00:00:01Z", durationMs: 1000,
16
+ steps: [{ ordinal: 0, phase: "extract", plugin: "django-domain", input: "source", output: "data", status: "written", durationMs: 10, fileCount: 1, changedCount: 1, files: ["data/domain.json"], warnings: [message], diagnostics: [{ ...warning, message, count: 1, project: "", phase: "extract" }] }],
17
+ }, "current");
18
+ expect(setup.run?.steps[0]?.diagnostics[0]?.aggregateCandidates).toEqual(candidates);
19
+ expect(setup.run?.steps[0]?.diagnostics[0]?.message.length).toBeLessThan(500);
20
+ });
21
+
22
+ it("leaves old warnings usable and rejects malformed or duplicate choices", () => {
23
+ expect(djangoAggregateCandidates("legacy warning")).toBeNull();
24
+ expect(djangoAggregateCandidates("; aggregate candidates: {broken")).toBeNull();
25
+ for (const value of [{ app: "../records", models: candidates.models }, { ...candidates, models: [candidates.models[0], candidates.models[0]] }, { ...candidates, models: [{ name: "Record", path: "models.py", line: 0 }] }]) {
26
+ expect(djangoAggregateCandidates(`; aggregate candidates: ${JSON.stringify(value)}`)).toBeNull();
27
+ }
28
+ });
29
+ });
@@ -0,0 +1,5 @@
1
+ // The parser itself is django-aggregates.mjs, which the CLI's `init` reads
2
+ // from node_modules, where Node strips no types. This name is what the site
3
+ // imports, so the .mjs stays the one place it is written.
4
+ export { djangoAggregateCandidates, djangoAggregateMessage } from "./django-aggregates.mjs";
5
+ export type { DjangoAggregateCandidates } from "./django-aggregates.mjs";
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Catalog, Deployment } from "../catalog";
3
+ import { narrowToEnvironments, servicesDeployedIn } from "./environments";
4
+
5
+ const service = (id: string, path: string) => ({
6
+ id,
7
+ slug: id.split(".")[1] ?? id,
8
+ name: id,
9
+ repo: "github.com/acme/shop",
10
+ path,
11
+ readme: "",
12
+ provides: [],
13
+ consumes: [],
14
+ aggregates: [],
15
+ });
16
+
17
+ const placed = (name: string, environment: string, path: string, extra: Partial<Deployment> = {}): Deployment => ({
18
+ id: `argocd/${name}`,
19
+ name,
20
+ project: "shop",
21
+ environment,
22
+ cluster: "in-cluster",
23
+ namespace: "shop",
24
+ repo: "github.com/acme/shop",
25
+ path,
26
+ targetRevision: "main",
27
+ revision: "a".repeat(40),
28
+ tool: "kustomize",
29
+ url: `https://argocd.example.com/applications/argocd/${name}`,
30
+ ...extra,
31
+ });
32
+
33
+ const catalog: Catalog = {
34
+ generatedAt: "",
35
+ commit: "",
36
+ contexts: [
37
+ {
38
+ id: "shop",
39
+ slug: "shop",
40
+ name: "Shop",
41
+ summary: "",
42
+ services: [service("shop.cart", "services/cart"), service("shop.oms", "services/oms")],
43
+ },
44
+ {
45
+ id: "payments",
46
+ slug: "payments",
47
+ name: "Payments",
48
+ summary: "",
49
+ services: [service("payments.ledger", "services/ledger")],
50
+ },
51
+ ],
52
+ defs: {},
53
+ flows: [],
54
+ adrs: [],
55
+ deployments: [
56
+ placed("cart", "prod", "services/cart/deploy"),
57
+ placed("cart-staging", "staging", "services/cart/deploy"),
58
+ placed("oms", "prod", "services/oms/deploy"),
59
+ // No environment label and no cluster name: placed by the one word left.
60
+ placed("ledger", "", "services/ledger/deploy", { cluster: "" }),
61
+ ],
62
+ };
63
+
64
+ describe("the estate narrowed to an environment", () => {
65
+ it("names the services the snapshot places in the environments asked for", () => {
66
+ expect([...servicesDeployedIn(catalog, new Set(["prod"]))]).toEqual(["shop.cart", "shop.oms"]);
67
+ expect([...servicesDeployedIn(catalog, new Set(["staging"]))]).toEqual(["shop.cart"]);
68
+ expect([...servicesDeployedIn(catalog, new Set(["prod", "staging"]))]).toEqual(["shop.cart", "shop.oms"]);
69
+ expect([...servicesDeployedIn(catalog, new Set(["unplaced"]))]).toEqual(["payments.ledger"]);
70
+ expect(servicesDeployedIn(catalog, new Set(["nowhere"])).size).toBe(0);
71
+ });
72
+
73
+ it("keeps only the deployed services, drops a context left empty, and leaves the rest of the catalog alone", () => {
74
+ const staging = narrowToEnvironments(catalog, new Set(["staging"]));
75
+ expect(staging.contexts.map((c) => [c.id, c.services.map((s) => s.id)])).toEqual([["shop", ["shop.cart"]]]);
76
+ expect(staging.deployments).toBe(catalog.deployments);
77
+ expect(staging.flows).toBe(catalog.flows);
78
+ });
79
+
80
+ it("is the whole estate when no environment is chosen", () => {
81
+ expect(narrowToEnvironments(catalog, new Set())).toBe(catalog);
82
+ });
83
+ });
@@ -0,0 +1,57 @@
1
+ // The estate narrowed to what runs somewhere.
2
+ //
3
+ // A map answers "who leans on whom"; with an environment chosen it answers
4
+ // "who leans on whom IN STAGING" - and the honest way to answer that is to
5
+ // take the services the snapshot does not place there out of the catalog
6
+ // and let the same graph, map and counts run over what is left. A call to
7
+ // a service that is not deployed there is then an edge that leaves the
8
+ // chart, which is what it is. Nothing here is a second graph; it is the
9
+ // first graph over a smaller estate.
10
+
11
+ import type { Catalog } from "../catalog";
12
+ import { allDeployments, deploys, environmentOf } from "../catalog";
13
+
14
+ /** The ids of the services the snapshot places in any of the environments named. */
15
+ export function servicesDeployedIn(
16
+ catalog: Catalog,
17
+ environments: ReadonlySet<string>,
18
+ ): Set<string> {
19
+ const placed = allDeployments(catalog).filter((deployment) =>
20
+ environments.has(environmentOf(deployment)),
21
+ );
22
+ const out = new Set<string>();
23
+ for (const context of catalog.contexts) {
24
+ for (const service of context.services) {
25
+ if (placed.some((deployment) => deploys(deployment, service))) {
26
+ out.add(service.id);
27
+ }
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * The catalog with only the services deployed in the environments named,
35
+ * and only the contexts that still hold one. No environment named is the
36
+ * whole estate: an empty filter is no filter, as everywhere else in the app.
37
+ *
38
+ * Flows, ADRs, stores and terms are left as they are: a flow through a
39
+ * service that is not in staging is still the flow, and the reader asked
40
+ * about the map, not the archive.
41
+ */
42
+ export function narrowToEnvironments(
43
+ catalog: Catalog,
44
+ environments: ReadonlySet<string>,
45
+ ): Catalog {
46
+ if (environments.size === 0) return catalog;
47
+ const kept = servicesDeployedIn(catalog, environments);
48
+ return {
49
+ ...catalog,
50
+ contexts: catalog.contexts
51
+ .map((context) => ({
52
+ ...context,
53
+ services: context.services.filter((service) => kept.has(service.id)),
54
+ }))
55
+ .filter((context) => context.services.length > 0),
56
+ };
57
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createIntegrationStore, normalizeIntegrationUrl } from "./integration-url";
3
+
4
+ describe("integration URLs", () => {
5
+ it("accepts web URLs and rejects values that cannot be opened safely", () => {
6
+ expect(normalizeIntegrationUrl(" https://docs.example/ ")).toBe(
7
+ "https://docs.example",
8
+ );
9
+ expect(normalizeIntegrationUrl("https://docs.example/wiki#top")).toBe(
10
+ "https://docs.example/wiki",
11
+ );
12
+ expect(normalizeIntegrationUrl("javascript:alert(1)")).toBeNull();
13
+ expect(normalizeIntegrationUrl("not a URL")).toBeNull();
14
+ expect(normalizeIntegrationUrl(" ")).toBe("");
15
+ });
16
+
17
+ it("keeps a normalized value and ignores one that cannot be opened", () => {
18
+ // vitest runs under node, so the store keeps the value for this session
19
+ // only; what is asserted is the behaviour the page sees either way.
20
+ const store = createIntegrationStore("portolan.integrations.test");
21
+ store.getState().setUrl("https://docs.example/");
22
+ expect(store.getState().url).toBe("https://docs.example");
23
+
24
+ store.getState().setUrl("javascript:alert(1)");
25
+ expect(store.getState().url).toBe("https://docs.example");
26
+
27
+ store.getState().setUrl("");
28
+ expect(store.getState().url).toBe("");
29
+ });
30
+ });
@@ -0,0 +1,63 @@
1
+ // What every reader-side integration shares: a web address the reader typed
2
+ // in, kept in this browser and nowhere else.
3
+ //
4
+ // The value belongs to the reader, not to the catalog: two readers can point
5
+ // the same generated estate at different installations. It therefore lives in
6
+ // localStorage, like the editor and display preferences, and never reaches
7
+ // portolan.json or a page that is committed.
8
+
9
+ import { create } from "zustand";
10
+
11
+ /**
12
+ * A trimmed http(s) URL without its fragment and trailing slash, "" for an
13
+ * empty value, and null for anything that cannot be opened safely - a
14
+ * javascript: address, or words that are not a URL at all.
15
+ */
16
+ export function normalizeIntegrationUrl(value: string): string | null {
17
+ const clean = value.trim();
18
+ if (!clean) return "";
19
+ try {
20
+ const url = new URL(clean);
21
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
22
+ url.hash = "";
23
+ return url.toString().replace(/\/$/, "");
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export interface IntegrationState {
30
+ url: string;
31
+ setUrl: (url: string) => void;
32
+ }
33
+
34
+ /** One store per integration, read from localStorage under its own key. */
35
+ export function createIntegrationStore(key: string) {
36
+ const read = (): string => {
37
+ try {
38
+ const value = localStorage.getItem(key) ?? "";
39
+ return normalizeIntegrationUrl(value) ?? "";
40
+ } catch {
41
+ return "";
42
+ }
43
+ };
44
+
45
+ const write = (value: string): void => {
46
+ try {
47
+ if (value) localStorage.setItem(key, value);
48
+ else localStorage.removeItem(key);
49
+ } catch {
50
+ /* private mode: keep the value for this session */
51
+ }
52
+ };
53
+
54
+ return create<IntegrationState>()((set) => ({
55
+ url: read(),
56
+ setUrl: (url) => {
57
+ const normalized = normalizeIntegrationUrl(url);
58
+ if (normalized === null) return;
59
+ write(normalized);
60
+ set({ url: normalized });
61
+ },
62
+ }));
63
+ }
@@ -7,24 +7,13 @@
7
7
  // installation URL remains useful and opens Kafka UI without inventing a
8
8
  // cluster name.
9
9
 
10
- import { create } from "zustand";
11
10
  import type { Catalog, Channel } from "../catalog";
12
11
  import { walkSteps } from "../catalog";
12
+ import { createIntegrationStore, normalizeIntegrationUrl } from "./integration-url";
13
13
 
14
14
  export const KAFKA_UI_KEY = "portolan.integrations.kafka-ui";
15
15
 
16
- export function normalizeKafkaUiUrl(value: string): string | null {
17
- const clean = value.trim();
18
- if (!clean) return "";
19
- try {
20
- const url = new URL(clean);
21
- if (url.protocol !== "http:" && url.protocol !== "https:") return null;
22
- url.hash = "";
23
- return url.toString().replace(/\/$/, "");
24
- } catch {
25
- return null;
26
- }
27
- }
16
+ export const normalizeKafkaUiUrl = normalizeIntegrationUrl;
28
17
 
29
18
  /** Build Kafbat's topic route when the configured URL names a cluster. */
30
19
  export function kafkaUiTopicUrl(
@@ -71,35 +60,4 @@ export function isKafkaChannel(
71
60
  || handoffChannels.has(channel.address);
72
61
  }
73
62
 
74
- function read(): string {
75
- try {
76
- const value = localStorage.getItem(KAFKA_UI_KEY) ?? "";
77
- return normalizeKafkaUiUrl(value) ?? "";
78
- } catch {
79
- return "";
80
- }
81
- }
82
-
83
- function write(value: string): void {
84
- try {
85
- if (value) localStorage.setItem(KAFKA_UI_KEY, value);
86
- else localStorage.removeItem(KAFKA_UI_KEY);
87
- } catch {
88
- /* private mode: keep the value for this session */
89
- }
90
- }
91
-
92
- interface KafkaUiState {
93
- url: string;
94
- setUrl: (url: string) => void;
95
- }
96
-
97
- export const useKafkaUi = create<KafkaUiState>()((set) => ({
98
- url: read(),
99
- setUrl: (url) => {
100
- const normalized = normalizeKafkaUiUrl(url);
101
- if (normalized === null) return;
102
- write(normalized);
103
- set({ url: normalized });
104
- },
105
- }));
63
+ export const useKafkaUi = createIntegrationStore(KAFKA_UI_KEY);