@shortlink-org/portolan 0.3.0 → 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 (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
@@ -21,25 +21,31 @@ const ENVIRONMENT = new Environment({
21
21
  const COMPILED = new Map();
22
22
 
23
23
  const RULES = [
24
- rule("openapi.missing-operation-id", /\bhas no operationId\b/i, "warning", "Add stable operationId values to the OpenAPI operations."),
24
+ rule("openapi.missing-operation-id", /\bno operationId\b/i, "warning", "Add stable operationId values to the OpenAPI operations."),
25
25
  rule("schema.duplicate-declaration", /\bduplicate declaration\b/i, "warning", "Remove or reconcile duplicate schema declarations; Portolan currently uses the first."),
26
26
  rule("analysis.typed-fallback", /\btyped call graph unavailable\b/i, "warning", "Run an extractor build with typed analysis support or inspect calls found by the syntax fallback."),
27
27
  rule("catalog.unresolved-call", /\bcalls .+ which nothing in this catalog resolves\b/i, "warning", "Add or correct the provider contract, then regenerate to resolve the call."),
28
- rule("catalog.unmapped-proto-peer", /\bmanifest names no peer for that package\b/i, "warning", "Map the protobuf package under peers, or declare it under externals."),
28
+ rule("catalog.unmapped-proto-peer", /\bmanifest names no peer for (?:that package|it)\b/i, "warning", "Map the protobuf package under peers, or declare it under externals."),
29
29
  rule("flow.unresolved-step", /\bstep .+ is unresolved\b/i, "warning", "Declare the referenced endpoint, message, or store so this flow step can be joined."),
30
- rule("flow.unknown-port", /\bis neither a domain port nor a use case\b/i, "warning", "Model this dependency as a domain port or use case, or accept that its calls stay outside the flow."),
30
+ rule("flow.unknown-port", /\bis neither a domain port(?:,| nor) a use case\b/i, "warning", "Model this dependency as a domain port or use case, or accept that its calls stay outside the flow."),
31
31
  rule("flow.unreached-event", /\bno flow reaches this event\b/i, "warning", "Connect the event to its publisher flow or remove the stale event declaration."),
32
32
  rule("flow.unknown-event", /\breacts to the message named .+ which no event .+ declares is called\b/i, "warning", "Declare the event name used by the handler or correct the handler mapping."),
33
33
  rule("django.invalid-aggregate-root", /\baggregates names .+ and no model there is called that\b/i, "warning", "Choose an existing concrete model for the application's aggregates option."),
34
- rule("django.ambiguous-aggregate-root", /\bmodels to choose from: name the root in the aggregates option\b/i, "warning", "Choose the aggregate root explicitly in the django-domain aggregates option."),
35
34
  rule("django.unknown-http-verb", /\bmounted as an HTTP view, but no HTTP verb is declared\b/i, "warning", "Declare the accepted HTTP methods on the view or route."),
36
35
  rule("river.missing-worker", /\bno registered Worker\b/i, "warning", "Register the River worker in this component or remove the unmatched insert."),
37
- rule("watermill.unresolved-topic", /\bWatermill .+ topic could not be resolved\b/i, "warning", "Use a literal, constant, or configuration default for the Watermill topic."),
36
+ rule("watermill.unresolved-topic", /\bWatermill .+\btopic (?:generator could not be resolved|unresolved)\b/i, "warning", "Use a literal, constant, or configuration default for the Watermill topic."),
38
37
  rule("messaging.unresolved-subject", /\bsubject of .+ could not be resolved\b/i, "warning", "Use a literal, constant, configuration default, or visible caller argument for the subject."),
38
+ rule("messaging.unresolved-queue", /\bqueue of .+ could not be resolved\b/i, "warning", "Use a literal, constant, configuration default, constructor argument, or visible caller argument for the queue."),
39
+ rule("terraform.unresolved-name", /\b(?:could not be resolved to a literal, a variable default, a local or a module argument|is named with \w+_prefix, so its name is decided at apply time|sets no \w+, so its name is decided at apply time)\b/i, "warning", "Name the resource with a literal, a variable default, a local or a module argument so the catalog can address it."),
40
+ rule("terraform.name-at-apply-time", /\bis `[^`]*`, with .+ decided at apply time\b/i, "info", "The literal part of the name is listed; the rest is filled when Terraform applies."),
41
+ rule("terraform.unresolved-reference", /\bcould not be followed to (?:a queue, a table or a stream|an? \w+(?: \w+)*) declared here\b/i, "warning", "Reference the resource by its Terraform address, or declare it in this module."),
42
+ rule("terraform.module-skipped", /\bmodule ".+" (?:comes from|calls) .+ and is not read\b/i, "info", "Vendor the module under the input root to have its resources read."),
43
+ rule("terraform.not-read", /\bis not read: .+ is not part of this reader yet\b/i, "info", "This AWS product is not modelled yet; the resource is listed so the gap is visible."),
39
44
  rule("store.external-migrations", /\bmigrations are applied from .+ whose schema is not in this tree\b/i, "warning", "Vendor or expose the external migrations so their tables can be included in the store."),
40
45
  rule("store.missing-foreign-table", /\bcolumn .+ references .+ which no migration here creates\b/i, "warning", "Include the referenced table migration or correct the foreign-key target."),
41
46
  rule("schema.unresolved-type", /\bis not declared in the protos read here\b/i, "warning", "Include the imported protobuf declaration or map the type to an external schema."),
42
- rule("source.offline-cache", /\bnot fetched \(offline\)\b/i, "info", "Regenerate with network access when the vendored copy must be refreshed."),
47
+ rule("source.offline-cache", /\bnot (?:fetched|read) \(offline\)/i, "info", "Regenerate with network access when the vendored copy must be refreshed."),
48
+ rule("source.unreachable", /\bnot (?:fetched|read) \((?!offline\))/i, "warning", "The far end could not be reached and the committed copy was used; check the server and the credential, then regenerate."),
43
49
  rule("source.unpinned", /\bnot pinned\b/i, "warning", "Pin the source to an immutable commit or schema version."),
44
50
  rule("source.parse-failed", /\bcould not (?:be read|parse|be parsed|be encoded)\b/i, "error", "Open the referenced source and fix the parse or read error."),
45
51
  rule("extraction.no-match", /\b(?:no .+ (?:was|were) found|no .+ matched|declares no |no models in this application)\b/i, "info", "Confirm this capability is absent, or point the extractor at the source that declares it."),
@@ -1,10 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { diagnoseWarnings, warningPolicyProblems } from "./warning-policy.mjs";
2
+ import { classifyWarning, diagnoseWarnings, warningPolicyProblems } from "./warning-policy.mjs";
3
3
 
4
4
  describe("warning CEL policies", () => {
5
5
  const warnings = [
6
- "api: /book POST has no operationId; listed by verb and path",
7
- "api: /cancel POST has no operationId; listed by verb and path",
6
+ "aviacore-api: no operationId on 53 of 53 operations; listed by verb and path: GET /a, POST /b and 51 more",
7
+ "aviasupp-api: no operationId on 2 of 9 operations; listed by verb and path: POST /book, POST /cancel",
8
8
  ];
9
9
 
10
10
  it("evaluates typed CEL over the diagnostic and its repetition count", () => {
@@ -54,3 +54,40 @@ describe("warning CEL policies", () => {
54
54
  ]);
55
55
  });
56
56
  });
57
+
58
+ describe("warning classification", () => {
59
+ // Each message is worded the way the plugin that emits it words it today
60
+ // (grep the plugin for the phrase before editing a rule), so a policy that
61
+ // names the rule keeps matching during generation.
62
+ const emitted = [
63
+ ["openapi", "aviacore-api: no operationId on 53 of 53 operations; listed by verb and path: GET /a, POST /b and 51 more", "openapi.missing-operation-id"],
64
+ ["go-domain", "calls ledger.v1 and the manifest names no peer for that package; add it under `peers` to say which service answers, or under `externals` when the far end is outside the estate, until then the calls are unresolved", "catalog.unmapped-proto-peer"],
65
+ ["java-domain", "calls ledger.v1 and the manifest names no peer for it; add it under `peers` to say which service answers, or under `externals` when the far end is outside the estate, until then the calls are unresolved", "catalog.unmapped-proto-peer"],
66
+ ["go-domain", "internal/app: port `clock Clock` is neither a domain port nor a use case; its calls are left out of the flow", "flow.unknown-port"],
67
+ ["rust-domain", "src/app.rs: port `clock: Clock` is neither a domain port, a use case nor a client; its calls are left out of the flow", "flow.unknown-port"],
68
+ ["watermill", "internal/bus/router.go:12:3: Watermill handler orders is registered on topic `cfg.Topic`, which this reader cannot resolve to a literal, a constant, a config default or a caller's argument; the handler is kept with its topic unresolved", "watermill.unresolved-topic"],
69
+ ["watermill", "internal/bus/cqrs.go:40:5: Watermill CQRS handler topic generator could not be resolved", "watermill.unresolved-topic"],
70
+ ["go-nats", "internal/pub.go:8:2: subject of Publish could not be resolved to a literal, a constant, a config default or a caller's argument", "messaging.unresolved-subject"],
71
+ ["go-sqs", "internal/relay.go:15:2: queue of SendMessage could not be resolved to a literal, a constant, a config default, a constructor's argument or a caller's argument", "messaging.unresolved-queue"],
72
+ ["terraform", "deploy/queues.tf:12: name of aws_sqs_queue.opaque could not be resolved to a literal, a variable default, a local or a module argument", "terraform.unresolved-name"],
73
+ ["terraform", "deploy/queues.tf:40: aws_sqs_queue.generated is named with name_prefix, so its name is decided at apply time and is not read", "terraform.unresolved-name"],
74
+ ["terraform", "deploy/lambda.tf:52: aws_lambda_function.unnamed sets no function_name, so its name is decided at apply time and is not read", "terraform.unresolved-name"],
75
+ ["terraform", "deploy/data.tf:80: name of aws_sqs_queue.per_account is `fulfillment-events-{account_id}`, with {account_id} decided at apply time", "terraform.name-at-apply-time"],
76
+ ["terraform", "deploy/lambda.tf:30: event_source_arn of aws_lambda_event_source_mapping.in could not be followed to a queue, a table or a stream declared here", "terraform.unresolved-reference"],
77
+ ["terraform", "deploy/lambda.tf:30: function_name of aws_lambda_event_source_mapping.in could not be followed to an aws_lambda_function declared here", "terraform.unresolved-reference"],
78
+ ["terraform", "deploy/main.tf:36: module \"vpc\" comes from terraform-aws-modules/vpc/aws, which is not in this tree, and is not read", "terraform.module-skipped"],
79
+ ["terraform", "deploy/data.tf:69: aws_kinesis_stream.clicks is not read: Kinesis is not part of this reader yet", "terraform.not-read"],
80
+ ["git", "github.com/acme/ledger: not fetched (offline); the copy committed in this repository is used unchanged", "source.offline-cache"],
81
+ ["markdown", "flow.checkout step \"pay\" is unresolved: POST /pay", "flow.unresolved-step"],
82
+ ["rust-domain", "Confirm: src/policy.rs: Confirm.handle reacts to the message named \"payment.authorized\", which no event this repository declares is called; the step is unresolved", "flow.unknown-event"],
83
+ ];
84
+
85
+ it.each(emitted)("classifies what %s emits to a stable rule", (plugin, message, rule) => {
86
+ expect(classifyWarning(plugin, message).rule).toBe(rule);
87
+ });
88
+
89
+ it("no longer carries a rule for a warning no plugin emits", () => {
90
+ const message = "billing/records: no model called Records, and 2 models to choose from: name the root in the aggregates option";
91
+ expect(classifyWarning("django-domain", message).rule).toMatch(/^plugin\.django-domain\.other-/);
92
+ });
93
+ });
@@ -10,6 +10,9 @@ describe("crumbsFor", () => {
10
10
  expect(crumbsFor("/language")).toEqual([
11
11
  { label: "language", to: "/language" },
12
12
  ]);
13
+ expect(crumbsFor("/plugins")).toEqual([
14
+ { label: "plugins", to: "/plugins" },
15
+ ]);
13
16
  expect(crumbsFor("/problems")).toEqual([
14
17
  { label: "problems", to: "/problems" },
15
18
  ]);
@@ -85,6 +85,9 @@ export function crumbsFor(pathname: string): Crumb[] {
85
85
  if (parts[0] === "problems")
86
86
  return [{ label: "problems", to: paths.problems() }];
87
87
 
88
+ if (parts[0] === "plugins")
89
+ return [{ label: "plugins", to: paths.plugins() }];
90
+
88
91
  if (parts[0] === "registry") {
89
92
  const crumbs: Crumb[] = [{ label: "registry", to: paths.registry() }];
90
93
  const slug = parts[1];
@@ -18,6 +18,7 @@ import { FlowDetail } from "../pages/FlowDetail";
18
18
  import { FlowIndex } from "../pages/FlowIndex";
19
19
  import { AdrIndex } from "../pages/AdrIndex";
20
20
  import { Language } from "../pages/Language";
21
+ import { PluginIndex } from "../pages/PluginIndex";
21
22
  import { AdrDetail } from "../pages/AdrDetail";
22
23
  import { Overview } from "../pages/Overview";
23
24
  import { ContextMap } from "../pages/ContextMap";
@@ -119,6 +120,7 @@ function AppRoutes({
119
120
  />
120
121
  <Route path="/adrs" element={<AdrIndex />} />
121
122
  <Route path="/language" element={<Language />} />
123
+ <Route path="/plugins" element={<PluginIndex />} />
122
124
  <Route path="/adrs/:adr" element={<AdrDetail />} />
123
125
  <Route path="/problems" element={<Problems />} />
124
126
  <Route path="/settings/*" element={<Settings />} />
@@ -1,17 +1,17 @@
1
- import { useMemo } from "react";
2
1
  import { NavLink } from "react-router";
3
2
  import {
4
3
  Check,
5
4
  PanelLeftOpen,
5
+ Puzzle,
6
6
  Settings2,
7
7
  TriangleAlert,
8
8
  } from "lucide-react";
9
9
 
10
10
  import { allModules } from "../catalog";
11
+ import { catalog } from "../data";
11
12
  import { KindIcon } from "../components/kind";
12
13
  import { CompassRose } from "../components/logo";
13
- import { catalog, index } from "../data";
14
- import { allProblems } from "../lib/all-problems";
14
+ import { useProblems } from "../lib/use-problems";
15
15
  import type { Kind } from "../lib/kinds";
16
16
  import { paths } from "../routes";
17
17
 
@@ -28,7 +28,7 @@ export function SidebarBottomGroup() {
28
28
  // opens. The badge is red while anything on it is an error and amber when
29
29
  // only the schema disagrees with itself - a page of warnings is not a clean
30
30
  // estate, and a green tick over it would be the one lie the row can tell.
31
- const found = useMemo(() => allProblems(catalog, index), []);
31
+ const found = useProblems();
32
32
  const errors = found.filter((p) => p.severity === "error").length;
33
33
  const colour =
34
34
  errors > 0 ? "var(--status-unresolved)" : "var(--status-declared)";
@@ -76,6 +76,22 @@ export function SidebarBottomGroup() {
76
76
  </span>
77
77
  )}
78
78
  </NavLink>
79
+ <NavLink
80
+ to={paths.plugins()}
81
+ data-nav-item
82
+ title="Plugins — what Portolan can read, and what it makes"
83
+ style={({ isActive }) => ({
84
+ paddingLeft: indent(0),
85
+ background: isActive ? "var(--surface-2)" : undefined,
86
+ borderLeftWidth: 2,
87
+ borderLeftStyle: "solid",
88
+ borderLeftColor: isActive ? "var(--accent)" : "transparent",
89
+ })}
90
+ className="tree-row flex items-center gap-1.5 py-[3px] pr-2 text-muted t-micro transition-colors hover:bg-surface hover:text-ink"
91
+ >
92
+ <Puzzle size={14} aria-hidden className="block shrink-0" />
93
+ <span className="truncate">Plugins</span>
94
+ </NavLink>
79
95
  <NavLink
80
96
  to={paths.settings()}
81
97
  data-nav-item
@@ -6,6 +6,7 @@ import type {
6
6
  BoundedContext,
7
7
  Catalog,
8
8
  Column,
9
+ Deployment,
9
10
  Enum,
10
11
  Event,
11
12
  External,
@@ -21,12 +22,14 @@ import type {
21
22
  View,
22
23
  } from "./catalog-model.ts";
23
24
  import {
25
+ allDeployments,
24
26
  allExternals,
25
27
  allModules,
26
28
  allStores,
27
29
  allTerms,
28
30
  aggregateBlocks,
29
31
  columnId,
32
+ deploys,
30
33
  enumsOf,
31
34
  storeViews,
32
35
  viewReads,
@@ -140,6 +143,14 @@ export interface CatalogIndex {
140
143
  lineageInto: Map<string, string[]>;
141
144
  /** service id -> stores it owns, in catalog order */
142
145
  storesOwnedBy: Map<string, Store[]>;
146
+ /**
147
+ * service id -> where it runs, in snapshot order.
148
+ *
149
+ * Joined here rather than written by the fetcher, which reads a control
150
+ * plane and knows no service: an Application is this service's when it
151
+ * deploys from the service's repository, inside the service's directory.
152
+ */
153
+ deploymentsByService: Map<string, Deployment[]>;
143
154
  /** aggregate id -> tables naming it in `persists`, in catalog order */
144
155
  tablesByAggregate: Map<string, Table[]>;
145
156
  /** aggregate id -> views naming it in `persists`, in catalog order */
@@ -323,6 +334,19 @@ export function buildIndex(catalog: Catalog): CatalogIndex {
323
334
  }
324
335
  }
325
336
 
337
+ // Deployments come after the services because the join runs over them: a
338
+ // row is placed on every service whose repository and directory it deploys
339
+ // from, and on none when nobody in the estate claims that repository.
340
+ const deploymentsByService = new Map<string, Deployment[]>();
341
+ for (const deployment of allDeployments(catalog)) {
342
+ for (const service of serviceById.values()) {
343
+ if (!deploys(deployment, service)) continue;
344
+ const placed = deploymentsByService.get(service.id) ?? [];
345
+ placed.push(deployment);
346
+ deploymentsByService.set(service.id, placed);
347
+ }
348
+ }
349
+
326
350
  // Stores come after the domain tree because they point into it: a table says
327
351
  // which aggregate it persists, and a column which block it maps to, so both
328
352
  // are resolved against maps that are already full.
@@ -467,6 +491,7 @@ export function buildIndex(catalog: Catalog): CatalogIndex {
467
491
  lineageFrom,
468
492
  lineageInto,
469
493
  storesOwnedBy,
494
+ deploymentsByService,
470
495
  moduleById,
471
496
  moduleBySlug,
472
497
  interfacesByModule,
@@ -44,6 +44,13 @@ export interface Catalog {
44
44
  * to pin and renders as it did before there was anything to pin.
45
45
  */
46
46
  repos?: RepoPin[];
47
+ /**
48
+ * Where the estate's services run, read from what deploys them. Optional
49
+ * in the file and never optional downstream, exactly like `repos`: an
50
+ * estate nobody has pointed at a deployer renders as it did before there
51
+ * was anything to place.
52
+ */
53
+ deployments?: Deployment[];
47
54
  /**
48
55
  * The systems outside the estate that a service calls on a contract: a
49
56
  * payment provider, a tax API, a carrier. Nobody here builds one, so it has
@@ -173,6 +180,24 @@ export interface Service {
173
180
  * same as a service that cannot be built.
174
181
  */
175
182
  commands?: Command[];
183
+ /**
184
+ * The names this service answers on, read from what deploys it: a
185
+ * Kubernetes Service's name in its short, namespaced, `svc` and fully
186
+ * qualified forms, and the hosts of the Ingress or HTTPRoute in front of
187
+ * it. Written so that a call another service is configured to make to
188
+ * `pricing.shop.svc` can find the service that answers. Absent when
189
+ * nothing in the tree says where the service is reachable.
190
+ */
191
+ hosts?: string[];
192
+ /**
193
+ * The in-cluster names this service's workload is configured to reach,
194
+ * read out of its environment and config maps and reduced to the host
195
+ * alone. A value is never kept: not the variable it came from, not the
196
+ * scheme, port, path or credentials around the name. Only a name the
197
+ * cluster resolves qualifies, so a password in an environment variable is
198
+ * not something this list can hold by shape.
199
+ */
200
+ dials?: string[];
176
201
  }
177
202
 
178
203
  /**
@@ -377,7 +402,7 @@ export interface HTTPDestination {
377
402
  join?: { expression: string; source: string };
378
403
  /** Runtime URL modifiers after the proven join; not evaluated statically. */
379
404
  transforms?: { expression: string; source: string }[];
380
- resolution?: { basis: "full-path" | "exact-route" | "unique-suffix"; provider: string; route: string };
405
+ resolution?: { basis: "full-path" | "exact-route" | "unique-suffix" | "kubernetes-host"; provider: string; route: string };
381
406
  }
382
407
  export interface HTTPBaseURL {
383
408
  expression: string;
@@ -471,6 +496,104 @@ export interface RepoPin {
471
496
  /** The commit the copy was made of. Full sha: it is not resolved locally, so there is nothing to expand it against. */
472
497
  commit: string;
473
498
  }
499
+
500
+ /**
501
+ * One place a service runs: an Argo CD Application, as the deployer listed
502
+ * it, reduced to what a deploy changes.
503
+ *
504
+ * A list on the catalog rather than a field on `Service`, for two reasons.
505
+ * The fetcher that writes it reads a control plane, not a service's tree, so
506
+ * it does not know which service an Application is; the join is made where
507
+ * the whole estate is known, by the repository and path the Application
508
+ * deploys from against the ones a service says it lives at. And one service
509
+ * stands in several places - staging, production, a second region - and
510
+ * each is its own row.
511
+ *
512
+ * Nothing here moves without a deploy. Health, sync state and the time of
513
+ * the last operation move on their own, and a page regenerated from the
514
+ * tree cannot follow them; they are not kept.
515
+ */
516
+ export interface Deployment {
517
+ /** `<argocd namespace>/<application name>`: unique per control plane. */
518
+ id: string;
519
+ /** The Application's name, as `argocd app get` takes it. */
520
+ name: string;
521
+ /** The Argo CD project it belongs to. */
522
+ project: string;
523
+ /**
524
+ * Where a reader would say it runs: the application's environment label,
525
+ * else the cluster it deploys to. What the page groups by.
526
+ */
527
+ environment: string;
528
+ /** The destination cluster, by its Argo CD name; `in-cluster` for the one Argo CD runs in. */
529
+ cluster: string;
530
+ /** The destination namespace. */
531
+ namespace: string;
532
+ /** The repository it deploys from, spelled the way `Service.repo` spells it. Empty for a chart from a registry. */
533
+ repo: string;
534
+ /** The directory in that repository the manifests are read from, as a reader would type it. Empty for a chart. */
535
+ path: string;
536
+ /** The Helm chart name, when the source is a chart registry rather than a repository. */
537
+ chart?: string;
538
+ /** What the Application tracks: a branch, a tag, a chart version. */
539
+ targetRevision: string;
540
+ /** What is deployed now: the commit, or chart version, the last sync resolved `targetRevision` to. */
541
+ revision: string;
542
+ /** `helm`, `kustomize`, `directory` or `plugin`, as Argo CD says it; empty until it has said. */
543
+ tool: string;
544
+ /** The Application in the Argo CD UI. */
545
+ url: string;
546
+ /**
547
+ * The service it deploys, by id, when the Application's labels say so:
548
+ * `app.kubernetes.io/part-of` and `app.kubernetes.io/name`, the same
549
+ * labels a workload carries. A GitOps repository's Application points at
550
+ * an overlay, not at the service's directory, so the path alone cannot
551
+ * place it; the labels can, and an ApplicationSet stamps them on every
552
+ * Application it makes. Absent when the labels are, and the path decides.
553
+ */
554
+ service?: string;
555
+ /** The container images the deployed resources run, as the deployer summarised them, sorted. */
556
+ images?: string[];
557
+ /**
558
+ * Who said so. `manifest` is a row an extractor read out of a GitOps
559
+ * tree: what should run. `api` is a row a fetcher read off the deployer:
560
+ * what does. `both` is one the merge folded from the two, which is the
561
+ * ordinary case for an estate that keeps both, and the only one that can
562
+ * carry drift. Absent reads as `api`, which is what every row was before
563
+ * there were trees to read.
564
+ */
565
+ basis?: DeploymentBasis;
566
+ /**
567
+ * What the tree says where it and the deployer disagree, set by the merge
568
+ * when both spoke (portolan.0013). A field is present only when the two
569
+ * differ; a row with no drift has no field at all.
570
+ */
571
+ drift?: DeploymentDrift;
572
+ }
573
+
574
+ export type DeploymentBasis = "manifest" | "api" | "both";
575
+
576
+ export const DEPLOYMENT_BASES: readonly DeploymentBasis[] = [
577
+ "manifest",
578
+ "api",
579
+ "both",
580
+ ] as const;
581
+
582
+ /** The GitOps tree's word on the fields where the deployer says otherwise. */
583
+ export interface DeploymentDrift {
584
+ project?: string;
585
+ cluster?: string;
586
+ namespace?: string;
587
+ path?: string;
588
+ targetRevision?: string;
589
+ /** The images the overlay pins, when they are not the ones running. */
590
+ images?: string[];
591
+ }
592
+
593
+ /** The basis a row carries, `api` when it carries none. */
594
+ export function deploymentBasis(deployment: Deployment): DeploymentBasis {
595
+ return deployment.basis ?? "api";
596
+ }
474
597
  export interface Aggregate {
475
598
  id: string;
476
599
  slug: string;
@@ -537,6 +660,15 @@ export interface Operation {
537
660
  * estate can only reach from inside, which is sometimes exactly the point.
538
661
  */
539
662
  exposedBy?: string[];
663
+ /**
664
+ * What the caller hands in: the command's or query's own shape, as the
665
+ * message class declares it - the fields of `CreateCourseCommand`. Absent
666
+ * when the extractor does not read messages; empty is a message that
667
+ * carries nothing, `FindCoursesCounterQuery`.
668
+ */
669
+ fields?: Field[];
670
+ /** Where the handler is, `path:line`, for the reader who wants the code. */
671
+ source?: string;
540
672
  }
541
673
 
542
674
  /**
@@ -687,7 +819,31 @@ export interface Field {
687
819
  ref?: string;
688
820
  /** Protobuf field number; absent for sources whose wire has no field numbers. */
689
821
  number?: number;
822
+ /**
823
+ * The source says the field must be sent: a Protovalidate `required`, a
824
+ * name in a JSON Schema `required` list. Absent means the source does not
825
+ * say, which in proto3 and OpenAPI alike means it may be left out.
826
+ */
827
+ required?: boolean;
828
+ /** What the source says a value must satisfy, in the order it said it. */
829
+ rules?: FieldRule[];
690
830
  } // ref -> defs key
831
+ /**
832
+ * One constraint on a field's value, in the catalog's own vocabulary so a
833
+ * Protovalidate `min_len` and a JSON Schema `minLength` are one rule:
834
+ * `min_len`, `max_len`, `len`, `pattern`, `prefix`, `suffix`, `contains`,
835
+ * `not_contains`, `format`, `gt`, `gte`, `lt`, `lte`, `const`, `in`,
836
+ * `not_in`, `multiple_of`, `min_items`, `max_items`, `unique`, `min_pairs`,
837
+ * `max_pairs`, `defined_only`, `lt_now`, `gt_now`, `cel`. A rule on what a
838
+ * list holds is prefixed `items.`; on a map's keys or values, `keys.` or
839
+ * `values.`. A custom option the catalog has no word for keeps the name the
840
+ * source gave it, `(acme.pii)`, so it is shown rather than lost.
841
+ */
842
+ export interface FieldRule {
843
+ name: string;
844
+ /** The bound as the source wrote it, text so a 64-bit number survives; absent for a bare flag such as `unique`. */
845
+ value?: string;
846
+ }
691
847
  export interface TypeDef {
692
848
  fields: Field[];
693
849
  }
@@ -710,6 +866,7 @@ export type StoreKind =
710
866
  | "mongodb"
711
867
  | "clickhouse"
712
868
  | "s3"
869
+ | "dynamodb"
713
870
  | "other";
714
871
 
715
872
  export const STORE_KINDS: readonly StoreKind[] = [
@@ -720,6 +877,7 @@ export const STORE_KINDS: readonly StoreKind[] = [
720
877
  "mongodb",
721
878
  "clickhouse",
722
879
  "s3",
880
+ "dynamodb",
723
881
  "other",
724
882
  ] as const;
725
883
 
@@ -918,6 +1076,47 @@ export interface Flow {
918
1076
  owner: string;
919
1077
  participants: Participant[]; // order is significant - it is the lane order
920
1078
  steps: FlowNode[];
1079
+ /**
1080
+ * Recordings of this flow running: one per trace a verifier kept, each
1081
+ * naming the steps it showed with what the spans said about them. Examples
1082
+ * of data, not evidence - the evidence is the status on the step.
1083
+ */
1084
+ examples?: FlowExample[];
1085
+ }
1086
+ /** One recorded run of a flow, read from one trace. */
1087
+ export interface FlowExample {
1088
+ /** "<recording>#<trace id>": unique within the flow, stable across runs. */
1089
+ id: string;
1090
+ /**
1091
+ * The file the trace was read from, relative to the verify step's input
1092
+ * root - where an uploaded recording is kept.
1093
+ */
1094
+ recording: string;
1095
+ traceId: string;
1096
+ /** When the root span started, RFC 3339 UTC; absent when the recording has no clock. */
1097
+ recordedAt?: string;
1098
+ /** Root start to the last end the trace shows. */
1099
+ durationMs: number;
1100
+ /** The steps this trace showed, in the order it showed them. */
1101
+ steps: ExampleStep[];
1102
+ }
1103
+ /**
1104
+ * What one span said about one step. Only an allowlist of attributes is
1105
+ * carried: names, verbs, routes, status codes - never a query text, a header
1106
+ * or a path with an id in it.
1107
+ */
1108
+ export interface ExampleStep {
1109
+ step: string;
1110
+ label?: string;
1111
+ durationMs: number;
1112
+ attributes?: Record<string, string>;
1113
+ }
1114
+ /**
1115
+ * How many recordings showed a step. On a declared step it accompanies the
1116
+ * raised status; on a step no source declares it is why the step is there.
1117
+ */
1118
+ export interface StepSeen {
1119
+ traces: number;
921
1120
  }
922
1121
  export interface FlowTrigger {
923
1122
  kind:
@@ -974,6 +1173,8 @@ export interface Step {
974
1173
  handoff?: FlowHandoff;
975
1174
  /** Repository call resolved to a concrete store operation after merge. */
976
1175
  storeAccess?: FlowStoreAccess;
1176
+ /** Recordings that showed this hop; set by a verifier. */
1177
+ seen?: StepSeen;
977
1178
  }
978
1179
  export interface HTTPResponse {
979
1180
  status?: number;
@@ -1029,6 +1230,12 @@ export interface AltBranch {
1029
1230
  steps: FlowNode[];
1030
1231
  /** True when the flow stops here instead of continuing past the alt. */
1031
1232
  terminal?: boolean;
1233
+ /**
1234
+ * Recordings that went this way, on a frame a verifier wrote where the
1235
+ * recordings parted. A branch with no steps and a count is the recordings
1236
+ * that went no further.
1237
+ */
1238
+ seen?: StepSeen;
1032
1239
  }
1033
1240
  export interface Loop {
1034
1241
  type: "loop";
@@ -1272,6 +1479,44 @@ export function allRepos(catalog: Catalog): RepoPin[] {
1272
1479
  return catalog.repos ?? [];
1273
1480
  }
1274
1481
 
1482
+ /** Every place a service runs. Absent means none, exactly as with repos. */
1483
+ export function allDeployments(catalog: Catalog): Deployment[] {
1484
+ return catalog.deployments ?? [];
1485
+ }
1486
+
1487
+ /**
1488
+ * Whether a deployment is of this service. The labels decide when the
1489
+ * Application carries them: `service` is the id, and an Application
1490
+ * labelled for one service is not another's however its path reads. Without
1491
+ * labels, the path: the same repository, and the manifests read from inside
1492
+ * the service's directory - or from anywhere in the repository when the
1493
+ * service is the whole of it. An Application that matches nothing is a fact
1494
+ * for the Problems page rather than for a guess.
1495
+ */
1496
+ export function deploys(deployment: Deployment, service: Service): boolean {
1497
+ if (deployment.service) return deployment.service === service.id;
1498
+ if (!deployment.repo || deployment.repo !== service.repo) return false;
1499
+ const root = service.path.replace(/^\/+|\/+$/g, "");
1500
+ if (!root) return true;
1501
+ return deployment.path === root || deployment.path.startsWith(`${root}/`);
1502
+ }
1503
+
1504
+ /**
1505
+ * The environment a reader would say a deployment stands in: the one the
1506
+ * deployer named, else the cluster, else the one word that says neither was
1507
+ * said. Every picture and filter groups by this, so it is decided once.
1508
+ */
1509
+ export function environmentOf(deployment: Deployment): string {
1510
+ return deployment.environment || deployment.cluster || "unplaced";
1511
+ }
1512
+
1513
+ /** Where this service runs, in catalog order: the snapshot's, which is by id. */
1514
+ export function deploymentsOf(catalog: Catalog, service: Service): Deployment[] {
1515
+ return allDeployments(catalog).filter((deployment) =>
1516
+ deploys(deployment, service),
1517
+ );
1518
+ }
1519
+
1275
1520
  /** Who to ask about a service, without the caller having to know the field is optional. */
1276
1521
  export function ownersOf(service: Service): string[] {
1277
1522
  return service.owners ?? [];