@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
@@ -0,0 +1,39 @@
1
+ // What a switch or a re-grade does to the manifest's entries.
2
+ //
3
+ // The Rules page and the Problems page both flip a rule; the entry they
4
+ // write is one shape, kept here so the two cannot write it differently.
5
+
6
+ import type { ProblemRule, ProblemRuleEntry, RuleSeverity } from "./problem-rules";
7
+
8
+ /** The entries with one rule switched. A shipped rule back on with nothing else to say loses its entry. */
9
+ export function switched(entries: ProblemRuleEntry[], rule: ProblemRule, enabled: boolean, reason: string): ProblemRuleEntry[] {
10
+ const existing = entries.find((entry) => entry.id === rule.id);
11
+ if (rule.builtin) {
12
+ const next: ProblemRuleEntry = { id: rule.id };
13
+ if (!enabled) next.enabled = false;
14
+ if (existing?.severity) next.severity = existing.severity;
15
+ const why = enabled ? existing?.reason : reason || existing?.reason;
16
+ if (why && (!enabled || next.severity)) next.reason = why;
17
+ if (enabled && !next.severity) return entries.filter((entry) => entry.id !== rule.id);
18
+ return existing ? entries.map((entry) => (entry.id === rule.id ? next : entry)) : [...entries, next];
19
+ }
20
+ return entries.map((entry) => {
21
+ if (entry.id !== rule.id) return entry;
22
+ const { enabled: _enabled, ...rest } = entry;
23
+ return enabled ? rest : { ...rest, enabled: false, ...(reason ? { reason } : {}) };
24
+ });
25
+ }
26
+
27
+ /** The entries with one rule's severity set; a shipped rule back at its own severity loses the field. */
28
+ export function regraded(entries: ProblemRuleEntry[], rule: ProblemRule, severity: RuleSeverity): ProblemRuleEntry[] {
29
+ const existing = entries.find((entry) => entry.id === rule.id);
30
+ if (rule.builtin) {
31
+ const next: ProblemRuleEntry = { id: rule.id };
32
+ if (existing?.enabled === false) next.enabled = false;
33
+ if (severity !== rule.defaultSeverity) next.severity = severity;
34
+ if (existing?.reason) next.reason = existing.reason;
35
+ if (next.enabled === undefined && !next.severity) return entries.filter((entry) => entry.id !== rule.id);
36
+ return existing ? entries.map((entry) => (entry.id === rule.id ? next : entry)) : [...entries, next];
37
+ }
38
+ return entries.map((entry) => (entry.id === rule.id ? { ...entry, severity } : entry));
39
+ }
@@ -6,36 +6,38 @@ import { rawCatalog } from "../test-catalog";
6
6
  import { buildIndex, validateCatalog } from "../catalog";
7
7
  import type { Catalog } from "../catalog";
8
8
  import type { Problem } from "./derive";
9
- import { dataProblems } from "./data-problems";
9
+ import { builtinProblems } from "./problem-rules";
10
+
11
+ const DATA = ["cross-service-fk", "cross-service-lineage", "cross-service-view", "shared-store", "persistence-drift", "column-type", "outbox-payload"];
10
12
 
11
13
  const clone = (): Catalog =>
12
14
  JSON.parse(JSON.stringify(rawCatalog)) as unknown as Catalog;
13
15
 
14
16
  function found(catalog: Catalog) {
15
- return dataProblems(catalog, buildIndex(validateCatalog(catalog)));
17
+ return builtinProblems(catalog, buildIndex(validateCatalog(catalog)), DATA);
16
18
  }
17
19
 
18
- const kinds = (list: { kind: string }[]) => list.map((p) => p.kind);
20
+ const kinds = (list: { rule: string }[]) => list.map((p) => p.rule);
19
21
 
20
22
  describe("the sample estate", () => {
21
23
  const problems = found(clone());
22
24
 
23
25
  it("reports the intentional cross-service foreign key", () => {
24
- const fk = problems.find((p) => p.kind === "cross-service-fk");
26
+ const fk = problems.find((p) => p.rule === "cross-service-fk");
25
27
  expect(fk?.id).toBe("delivery.core.pg.packages.order_id");
26
28
  expect(fk?.peer).toBe("shop.oms.pg.orders");
27
29
  expect(fk?.severity).toBe("error");
28
30
  });
29
31
 
30
32
  it("reports the table one service writes in another's database", () => {
31
- const shared = problems.find((p) => p.kind === "shared-store");
33
+ const shared = problems.find((p) => p.rule === "shared-store");
32
34
  expect(shared?.id).toBe("shop.oms.pg.price_snapshots");
33
35
  expect(shared?.peer).toBe("shop.pricing");
34
36
  expect(shared?.severity).toBe("error");
35
37
  });
36
38
 
37
39
  it("reports no persistence drift: every table's columns follow its aggregate", () => {
38
- expect(problems.filter((p) => p.kind === "persistence-drift")).toEqual([]);
40
+ expect(problems.filter((p) => p.rule === "persistence-drift")).toEqual([]);
39
41
  });
40
42
 
41
43
  it("reports a renamed table whose columns did not follow", () => {
@@ -45,13 +47,13 @@ describe("the sample estate", () => {
45
47
  ?.tables.find((t) => t.name === "orders");
46
48
  if (!orders) throw new Error("fixture has no orders table");
47
49
  for (const column of orders.columns) if (column.maps) column.maps = `Order.${column.name}Gone`;
48
- const drift = found(drifted).find((p) => p.kind === "persistence-drift");
50
+ const drift = found(drifted).find((p) => p.rule === "persistence-drift");
49
51
  expect(drift?.id).toBe("shop.oms.pg.orders");
50
52
  expect(drift?.severity).toBe("warning");
51
53
  });
52
54
 
53
55
  it("reports every type disagreement and nothing else", () => {
54
- const types = problems.filter((p) => p.kind === "column-type");
56
+ const types = problems.filter((p) => p.rule === "column-type");
55
57
  expect(types.map((p) => p.id)).toEqual([
56
58
  "shop.oms.pg.orders.id",
57
59
  "shop.oms.pg.order_items.quantity",
@@ -64,7 +66,7 @@ describe("the sample estate", () => {
64
66
  });
65
67
 
66
68
  it("reports the value delivery copies out of the OMS schema", () => {
67
- const copied = problems.filter((p) => p.kind === "cross-service-lineage");
69
+ const copied = problems.filter((p) => p.rule === "cross-service-lineage");
68
70
  expect(copied.map((p) => p.id)).toEqual([
69
71
  "delivery.core.pg.packages.ship_to",
70
72
  ]);
@@ -78,7 +80,7 @@ describe("the sample estate", () => {
78
80
  it("says nothing about lineage that stays inside one service", () => {
79
81
  const inside = problems.filter(
80
82
  (p) =>
81
- p.kind === "cross-service-lineage" &&
83
+ p.rule === "cross-service-lineage" &&
82
84
  p.id.startsWith("shop.oms.pg.outbox"),
83
85
  );
84
86
  expect(inside).toEqual([]);
@@ -104,7 +106,7 @@ describe("cases that only look like problems", () => {
104
106
  const problems = found(clone());
105
107
  const inside = problems.filter(
106
108
  (p) =>
107
- p.kind === "cross-service-fk" &&
109
+ p.rule === "cross-service-fk" &&
108
110
  p.id.startsWith("shop.oms.pg.order_items"),
109
111
  );
110
112
  expect(inside).toEqual([]);
@@ -120,7 +122,7 @@ describe("cases that only look like problems", () => {
120
122
  stops.persists = { aggregate: "shop.oms.order" };
121
123
 
122
124
  const shared = found(catalog).filter(
123
- (p) => p.kind === "shared-store" && p.id === stops.id,
125
+ (p) => p.rule === "shared-store" && p.id === stops.id,
124
126
  );
125
127
  expect(shared).toEqual([]);
126
128
  });
@@ -129,7 +131,7 @@ describe("cases that only look like problems", () => {
129
131
  const problems = found(clone());
130
132
  const derived = problems.filter(
131
133
  (p) =>
132
- p.kind === "persistence-drift" &&
134
+ p.rule === "persistence-drift" &&
133
135
  p.id === "delivery.core.pg.route_stops",
134
136
  );
135
137
  expect(derived).toEqual([]);
@@ -139,7 +141,7 @@ describe("cases that only look like problems", () => {
139
141
  const problems = found(clone());
140
142
  expect(
141
143
  problems.filter(
142
- (p) => p.kind === "persistence-drift" && p.id === "shop.oms.pg.orders",
144
+ (p) => p.rule === "persistence-drift" && p.id === "shop.oms.pg.orders",
143
145
  ),
144
146
  ).toEqual([]);
145
147
  });
@@ -154,7 +156,7 @@ describe("an outbox with nothing in it", () => {
154
156
  if (!outbox) throw new Error("fixture has no outbox");
155
157
  outbox.columns = outbox.columns.filter((c) => c.type !== "jsonb");
156
158
 
157
- const problem = found(catalog).find((p) => p.kind === "outbox-payload");
159
+ const problem = found(catalog).find((p) => p.rule === "outbox-payload");
158
160
  expect(problem?.severity).toBe("warning");
159
161
  expect(problem?.id).toBe("shop.oms.pg.outbox");
160
162
  expect(problem?.peer).toBe("shop.oms.pg");
@@ -174,5 +176,5 @@ describe("a catalog with no stores", () => {
174
176
 
175
177
  // A compile-time check that the shared shape really is shared: the page renders
176
178
  // one row component over both lists, so the two must stay one type.
177
- const _shape: (p: Problem) => string = (p) => `${p.severity}:${p.kind}`;
179
+ const _shape: (p: Problem) => string = (p) => `${p.severity}:${p.rule}`;
178
180
  void _shape;
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildIndex } from "../catalog";
3
+ import type { Catalog, Deployment } from "../catalog";
4
+ import { builtinProblems } from "./problem-rules";
5
+
6
+ const deployProblems = (catalog: Catalog) => builtinProblems(catalog, buildIndex(catalog), ["deployment-unclaimed", "deployment-drift"]);
7
+
8
+ const service = (id: string, path: string) => ({
9
+ id,
10
+ slug: id.split(".")[1] ?? id,
11
+ name: id,
12
+ repo: "github.com/acme/shop",
13
+ path,
14
+ readme: "",
15
+ provides: [],
16
+ consumes: [],
17
+ aggregates: [],
18
+ });
19
+
20
+ const placed = (
21
+ name: string,
22
+ repo: string,
23
+ path: string,
24
+ extra: Partial<Deployment> = {},
25
+ ): Deployment => ({
26
+ id: `argocd/${name}`,
27
+ name,
28
+ project: "shop",
29
+ environment: "prod",
30
+ cluster: "in-cluster",
31
+ namespace: "shop",
32
+ repo,
33
+ path,
34
+ targetRevision: "main",
35
+ revision: "a".repeat(40),
36
+ tool: "kustomize",
37
+ url: `https://argocd.example.com/applications/argocd/${name}`,
38
+ ...extra,
39
+ });
40
+
41
+ const estate = (deployments: Deployment[]): Catalog => ({
42
+ generatedAt: "",
43
+ commit: "",
44
+ contexts: [
45
+ {
46
+ id: "shop",
47
+ slug: "shop",
48
+ name: "Shop",
49
+ summary: "",
50
+ services: [service("shop.cart", "services/cart"), service("shop.oms", "")],
51
+ },
52
+ ],
53
+ defs: {},
54
+ flows: [],
55
+ adrs: [],
56
+ deployments,
57
+ });
58
+
59
+ describe("deployments the catalog cannot place", () => {
60
+ it("is quiet when every Application deploys from a directory a service lives at", () => {
61
+ const catalog = estate([
62
+ placed("cart", "github.com/acme/shop", "services/cart/deploy"),
63
+ // A service whose path is the whole repository claims any directory of it.
64
+ placed("oms", "github.com/acme/shop", "anything/at/all"),
65
+ ]);
66
+ expect(deployProblems(catalog)).toEqual([]);
67
+ });
68
+
69
+ it("names the Application and where it deploys from, as a warning with no context", () => {
70
+ const catalog = estate([
71
+ placed("grafana", "github.com/acme/platform", "charts/grafana", {
72
+ namespace: "monitoring",
73
+ }),
74
+ ]);
75
+ const [problem, ...rest] = deployProblems(catalog);
76
+ expect(rest).toEqual([]);
77
+ expect(problem).toMatchObject({
78
+ rule: "deployment-unclaimed",
79
+ severity: "warning",
80
+ context: "",
81
+ service: "",
82
+ id: "argocd/grafana",
83
+ peer: "github.com/acme/platform/charts/grafana",
84
+ });
85
+ expect(problem?.note).toContain("grafana runs in prod, namespace monitoring");
86
+ expect(problem?.note).toContain("https://argocd.example.com/applications/argocd/grafana");
87
+ });
88
+
89
+ it("places by the labels when the Application carries them, and says so when they name nobody", () => {
90
+ const placedByLabel = estate([
91
+ placed("cart-prod", "github.com/acme/other", "envs/prod/shop/cart", { service: "shop.cart" }),
92
+ ]);
93
+ expect(deployProblems(placedByLabel)).toEqual([]);
94
+
95
+ const nobody = estate([
96
+ placed("grafana", "github.com/acme/platform", "charts/grafana", { service: "platform.grafana" }),
97
+ ]);
98
+ expect(deployProblems(nobody)[0]?.note).toContain("its labels name platform.grafana, which is no service of the estate");
99
+ });
100
+
101
+ it("reports drift on the service's row, in words, and stays quiet when the tree and the deployer agree", () => {
102
+ const agreed = estate([placed("cart-prod", "github.com/acme/shop", "services/cart/deploy", { basis: "both" })]);
103
+ expect(deployProblems(agreed)).toEqual([]);
104
+
105
+ const drifted = estate([
106
+ placed("cart-prod", "github.com/acme/shop", "services/cart/deploy", {
107
+ basis: "both",
108
+ images: ["ghcr.io/acme/cart:2.0.9"],
109
+ drift: { targetRevision: "release-2.1", images: ["ghcr.io/acme/cart:2.1.0"] },
110
+ }),
111
+ ]);
112
+ const [problem, ...rest] = deployProblems(drifted);
113
+ expect(rest).toEqual([]);
114
+ expect(problem).toMatchObject({ rule: "deployment-drift", severity: "warning", context: "shop", service: "shop.cart", id: "argocd/cart-prod", peer: "prod" });
115
+ expect(problem?.note).toBe(
116
+ "tracks release-2.1 in the tree, main deployed; pins ghcr.io/acme/cart:2.1.0 in the tree; running ghcr.io/acme/cart:2.0.9. https://argocd.example.com/applications/argocd/cart-prod",
117
+ );
118
+ });
119
+
120
+ it("says the chart when the Application deploys one from a registry", () => {
121
+ const catalog = estate([
122
+ placed("redis", "charts.bitnami.com/bitnami", "", { chart: "redis" }),
123
+ ]);
124
+ expect(deployProblems(catalog)[0]?.peer).toBe("chart redis");
125
+ });
126
+
127
+ it("does not place a deployment on a service in another repository at the same path", () => {
128
+ const catalog = estate([
129
+ placed("cart", "github.com/acme/other", "services/cart/deploy"),
130
+ ]);
131
+ expect(deployProblems(catalog)).toHaveLength(1);
132
+ });
133
+ });
@@ -1,8 +1,13 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { catalog } from "../data";
3
- import { contextStats, edgeCount, problems } from "./derive";
2
+ import { catalog, index } from "../data";
3
+ import { contextStats, edgeCount } from "./derive";
4
+ import { builtinProblems } from "./problem-rules";
5
+
6
+ import { buildIndex } from "../catalog";
4
7
  import type { Catalog } from "../catalog";
5
8
 
9
+ const problems = (of: Catalog) => builtinProblems(of, of === catalog ? index : buildIndex(of), ["rpc", "consumer"]);
10
+
6
11
  describe("problems", () => {
7
12
  it("finds every unresolved edge and nothing else", () => {
8
13
  for (const problem of problems(catalog)) {
@@ -13,7 +18,7 @@ describe("problems", () => {
13
18
  `${problem.id} names a service not in the catalog`,
14
19
  ).toBeDefined();
15
20
 
16
- if (problem.kind === "rpc") {
21
+ if (problem.rule === "rpc") {
17
22
  const call = service?.consumes.find((c) => c.id === problem.id);
18
23
  expect(call?.status).toBe("unresolved");
19
24
  expect(call?.peer).toBe(problem.peer);
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import type { Catalog, CatalogIndex, Service } from "../catalog";
3
- import { protoProblems } from "./proto-problems";
2
+ import { buildIndex } from "../catalog";
3
+ import type { Catalog, Service } from "../catalog";
4
+ import { builtinProblems } from "./problem-rules";
5
+
6
+ const PROTO = ["proto-missing", "proto-drift"];
4
7
 
5
8
  function service(id: string, overrides: Partial<Service> = {}): Service {
6
9
  const slug = id.slice(id.indexOf(".") + 1);
@@ -141,36 +144,17 @@ const copiedPricing = (overrides = {}) =>
141
144
  });
142
145
 
143
146
  function found(catalog: Catalog) {
144
- const serviceById = new Map<string, Service>();
145
- const serviceContext = new Map<string, Catalog["contexts"][number]>();
146
- const rpcProviderByMethod = new Map<string, Service>();
147
- for (const context of catalog.contexts) {
148
- for (const item of context.services) {
149
- serviceById.set(item.id, item);
150
- serviceContext.set(item.id, context);
151
- for (const provided of item.provides) {
152
- for (const method of provided.methods) {
153
- rpcProviderByMethod.set(`${provided.id}/${method.name}`, item);
154
- }
155
- }
156
- }
157
- }
158
- const index = {
159
- serviceById,
160
- serviceContext,
161
- rpcProviderByMethod,
162
- } as CatalogIndex;
163
- return protoProblems(catalog, index);
147
+ return builtinProblems(catalog, buildIndex(catalog), PROTO);
164
148
  }
165
149
 
166
- describe("protoProblems", () => {
150
+ describe("the proto rules", () => {
167
151
  it("reports a call whose provider answers on no such method", () => {
168
152
  const problems = found(
169
153
  catalogWith([pricing(), calling("pricing.v1.Pricing/ListPriceLists")]),
170
154
  );
171
155
 
172
156
  expect(problems).toHaveLength(1);
173
- expect(problems[0]?.kind).toBe("proto-missing");
157
+ expect(problems[0]?.rule).toBe("proto-missing");
174
158
  expect(problems[0]?.severity).toBe("error");
175
159
  // The near end is the CALLER: that is the copy to go and look at.
176
160
  expect(problems[0]?.service).toBe("shop.oms");
@@ -238,7 +222,7 @@ describe("protoProblems", () => {
238
222
 
239
223
  expect(problems).toHaveLength(1);
240
224
  expect(problems[0]).toMatchObject({
241
- kind: "proto-drift",
225
+ rule: "proto-drift",
242
226
  severity: "warning",
243
227
  service: "shop.oms",
244
228
  id: "pricing.v1.Pricing",
@@ -9,7 +9,9 @@ import {
9
9
  type Service,
10
10
  } from "../catalog";
11
11
  import { catalog as estate } from "../data";
12
- import { wireProblems } from "./wire-problems";
12
+ import { builtinProblems } from "./problem-rules";
13
+
14
+ const WIRE = ["shared-channel", "channel-undeclared", "channel-unpublished", "message-encoding", "subscription-unresolved"];
13
15
 
14
16
  function event(id: string, channel?: string): Event {
15
17
  const name = id.slice(id.lastIndexOf(".") + 1);
@@ -84,7 +86,7 @@ function catalogWith(services: Service[]): Catalog {
84
86
  }
85
87
 
86
88
  function found(catalog: Catalog) {
87
- return wireProblems(catalog, buildIndex(catalog));
89
+ return builtinProblems(catalog, buildIndex(catalog), WIRE);
88
90
  }
89
91
 
90
92
  const cart = () =>
@@ -95,7 +97,7 @@ const cart = () =>
95
97
  ]),
96
98
  ]);
97
99
 
98
- describe("wireProblems", () => {
100
+ describe("the wire rules", () => {
99
101
  it("reports each side of a channel two services publish on", () => {
100
102
  const oms = service("shop.oms", [
101
103
  aggregate("shop.oms.order", [
@@ -109,7 +111,7 @@ describe("wireProblems", () => {
109
111
  ["shop.oms", "shop.oms.order.OrderPlaced", "shop.cart"],
110
112
  ]);
111
113
  const [first] = problems;
112
- expect(first?.kind).toBe("shared-channel");
114
+ expect(first?.rule).toBe("shared-channel");
113
115
  expect(first?.severity).toBe("error");
114
116
  expect(first?.note).toContain("OrderPlaced");
115
117
  expect(first?.source).toBe("BasketCreated.ts");
@@ -150,7 +152,7 @@ describe("wireProblems", () => {
150
152
  );
151
153
  const problems = found(catalogWith([cart(), oms]));
152
154
 
153
- expect(problems.filter((p) => p.kind === "shared-channel")).toHaveLength(2);
155
+ expect(problems.filter((p) => p.rule === "shared-channel")).toHaveLength(2);
154
156
  const theirs = problems.find((p) => p.service === "shop.oms");
155
157
  // No event of theirs to point at, so the row leads to the service.
156
158
  expect(theirs?.id).toBe("shop.oms");
@@ -166,7 +168,7 @@ describe("wireProblems", () => {
166
168
  );
167
169
  const problems = found(catalogWith([one]));
168
170
 
169
- const undeclared = problems.filter((p) => p.kind === "channel-undeclared");
171
+ const undeclared = problems.filter((p) => p.rule === "channel-undeclared");
170
172
  expect(undeclared.map((p) => [p.id, p.peer])).toEqual([
171
173
  ["shop.cart.basket.BasketCreated", "shop.cart.basket"],
172
174
  ["shop.cart.basket.BasketCheckedOut", "shop.cart.basket"],
@@ -182,7 +184,7 @@ describe("wireProblems", () => {
182
184
  );
183
185
  const problems = found(catalogWith([one]));
184
186
 
185
- const promised = problems.filter((p) => p.kind === "channel-unpublished");
187
+ const promised = problems.filter((p) => p.rule === "channel-unpublished");
186
188
  expect(promised.map((p) => p.peer)).toEqual(["shop.cart.wishlist"]);
187
189
  expect(promised[0]?.id).toBe("shop.cart");
188
190
  expect(promised[0]?.note).toContain("cart.ItemWished");
@@ -202,7 +204,7 @@ describe("wireProblems", () => {
202
204
  channel("payments.ledger.payment", "receive payments.PaymentAuthorized"),
203
205
  );
204
206
  const problems = found(catalogWith([one])).filter(
205
- (p) => p.kind === "channel-undeclared" || p.kind === "channel-unpublished",
207
+ (p) => p.rule === "channel-undeclared" || p.rule === "channel-unpublished",
206
208
  );
207
209
 
208
210
  expect(problems).toEqual([]);
@@ -240,7 +242,7 @@ describe("wireProblems", () => {
240
242
  );
241
243
  const problems = found(catalogWith([cart(), oms]));
242
244
 
243
- expect(problems.filter((p) => p.kind === "subscription-unresolved")).toEqual(
245
+ expect(problems.filter((p) => p.rule === "subscription-unresolved")).toEqual(
244
246
  [],
245
247
  );
246
248
  });
@@ -253,7 +255,7 @@ describe("wireProblems", () => {
253
255
  const problems = found(catalogWith([cart(), oms]));
254
256
 
255
257
  const unresolved = problems.filter(
256
- (p) => p.kind === "subscription-unresolved",
258
+ (p) => p.rule === "subscription-unresolved",
257
259
  );
258
260
  expect(unresolved.map((p) => [p.service, p.peer])).toEqual([
259
261
  ["shop.oms", "billing.InvoiceRaised"],
@@ -270,7 +272,7 @@ describe("wireProblems", () => {
270
272
  const subscriber = speaking(service("shop.search", []), subscriberChannel);
271
273
 
272
274
  const mismatch = found(catalogWith([publisher, subscriber])).find(
273
- (problem) => problem.kind === "message-encoding",
275
+ (problem) => problem.rule === "message-encoding",
274
276
  );
275
277
  expect(mismatch).toMatchObject({
276
278
  severity: "error",
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Field } from "../catalog";
3
+ import { ruleMark, ruleMarks } from "./rules";
4
+
5
+ describe("ruleMark", () => {
6
+ it.each([
7
+ ["min_len", "1", "≥ 1 chars"],
8
+ ["max_len", "64", "≤ 64 chars"],
9
+ ["len", "3", "3 chars"],
10
+ ["min_items", "1", "≥ 1 items"],
11
+ ["gt", "0", "> 0"],
12
+ ["gte", "-1", "≥ -1"],
13
+ ["lte", "9007199254740993", "≤ 9007199254740993"],
14
+ ["const", "USD", "= USD"],
15
+ ["in", "USD, EUR", "one of USD, EUR"],
16
+ ["not_in", "XXX", "none of XXX"],
17
+ ["pattern", "^[A-Z]{3}$", "matches ^[A-Z]{3}$"],
18
+ ["format", "email", "email"],
19
+ ["multiple_of", "5", "multiple of 5"],
20
+ ["items.max_len", "64", "each ≤ 64 chars"],
21
+ ["keys.min_len", "1", "each key ≥ 1 chars"],
22
+ ["values.gte", "0", "each value ≥ 0"],
23
+ ["cel", "this.sku != ''", "cel"],
24
+ // A custom option is shown as written; `true` is the whole of a flag.
25
+ ["(acme.pii)", "true", "(acme.pii)"],
26
+ ["(acme.mask).style", "LAST_FOUR", "(acme.mask).style LAST_FOUR"],
27
+ // A rule the page has no words for yet is still shown, spelled as it came.
28
+ ["well_formed", "true", "well_formed true"],
29
+ ])("%s %s reads as %s", (name, value, text) => {
30
+ expect(ruleMark({ name, value })).toEqual({ text, title: `${name} = ${value}` });
31
+ });
32
+
33
+ it("a bare flag has no value in the title either", () => {
34
+ expect(ruleMark({ name: "unique" })).toEqual({ text: "unique", title: "unique" });
35
+ expect(ruleMark({ name: "lt_now" })).toEqual({ text: "in the past", title: "lt_now" });
36
+ });
37
+ });
38
+
39
+ describe("ruleMarks", () => {
40
+ const field = (extra: Partial<Field>): Field => ({ name: "x", type: "string", doc: "", ...extra });
41
+
42
+ it("puts required first and keeps the rules in the source's order", () => {
43
+ const marks = ruleMarks(field({ required: true, rules: [{ name: "max_len", value: "16" }, { name: "format", value: "uuid" }] }));
44
+ expect(marks.map((m) => m.text)).toEqual(["required", "≤ 16 chars", "uuid"]);
45
+ expect(marks[0]?.title).toBe("must be sent");
46
+ });
47
+
48
+ it("is empty for a field the source says nothing about", () => {
49
+ expect(ruleMarks(field({}))).toEqual([]);
50
+ });
51
+ });
@@ -0,0 +1,86 @@
1
+ // What a field's rules say, in words a reader scans.
2
+ //
3
+ // A rule arrives as the catalog spells it - `min_len 1`, `items.max_len 64`,
4
+ // `format email` - the same spelling whether a Protovalidate option or a JSON
5
+ // Schema keyword put it there. The page shows each as a short mark beside the
6
+ // type, "≥ 1 chars", and keeps the spelling for the tooltip, which is what
7
+ // a reader copies into a search of the source. A rule the catalog has no
8
+ // words for, a custom option, is shown as it was written rather than hidden.
9
+
10
+ import type { Field, FieldRule } from "../catalog";
11
+
12
+ export interface RuleMark {
13
+ /** The chip: a few words, or the value alone when the value is the point. */
14
+ text: string;
15
+ /** The rule as the fragment carries it: `min_len = 1`. */
16
+ title: string;
17
+ }
18
+
19
+ /** What a rule on the members of a list or map is about, by its prefix. */
20
+ const OF: Record<string, string> = {
21
+ items: "each",
22
+ keys: "each key",
23
+ values: "each value",
24
+ };
25
+
26
+ const WORDS: Record<string, (v: string) => string> = {
27
+ min_len: (v) => `≥ ${v} chars`,
28
+ max_len: (v) => `≤ ${v} chars`,
29
+ len: (v) => `${v} chars`,
30
+ min_items: (v) => `≥ ${v} items`,
31
+ max_items: (v) => `≤ ${v} items`,
32
+ min_pairs: (v) => `≥ ${v} entries`,
33
+ max_pairs: (v) => `≤ ${v} entries`,
34
+ gt: (v) => `> ${v}`,
35
+ gte: (v) => `≥ ${v}`,
36
+ lt: (v) => `< ${v}`,
37
+ lte: (v) => `≤ ${v}`,
38
+ const: (v) => `= ${v}`,
39
+ multiple_of: (v) => `multiple of ${v}`,
40
+ in: (v) => `one of ${v}`,
41
+ not_in: (v) => `none of ${v}`,
42
+ pattern: (v) => `matches ${v}`,
43
+ prefix: (v) => `starts with ${v}`,
44
+ suffix: (v) => `ends with ${v}`,
45
+ contains: (v) => `contains ${v}`,
46
+ not_contains: (v) => `never contains ${v}`,
47
+ format: (v) => v,
48
+ unique: () => "unique",
49
+ defined_only: () => "a defined value",
50
+ lt_now: () => "in the past",
51
+ gt_now: () => "in the future",
52
+ cel: () => "cel",
53
+ };
54
+
55
+ /** The mark for one rule. */
56
+ export function ruleMark(rule: FieldRule): RuleMark {
57
+ const title = rule.value === undefined ? rule.name : `${rule.name} = ${rule.value}`;
58
+ const value = rule.value ?? "";
59
+
60
+ // A custom option keeps its own name; `(acme.pii)` says more than any
61
+ // words the catalog could put to it.
62
+ if (rule.name.startsWith("(")) {
63
+ return { text: value && value !== "true" ? `${rule.name} ${value}` : rule.name, title };
64
+ }
65
+
66
+ const dot = rule.name.indexOf(".");
67
+ const of = dot > 0 ? OF[rule.name.slice(0, dot)] : undefined;
68
+ const base = of ? rule.name.slice(dot + 1) : rule.name;
69
+ const words = WORDS[base];
70
+ const text = words ? words(value) : value ? `${base} ${value}` : base;
71
+
72
+ return { text: of ? `${of} ${text}` : text, title };
73
+ }
74
+
75
+ /** Every mark a field wears: `required` first, then its rules as written. */
76
+ export function ruleMarks(field: Field): RuleMark[] {
77
+ const marks: RuleMark[] = [];
78
+ if (field.required) {
79
+ marks.push({ text: "required", title: "must be sent" });
80
+ }
81
+ for (const rule of field.rules ?? []) {
82
+ marks.push(ruleMark(rule));
83
+ }
84
+
85
+ return marks;
86
+ }
@@ -211,6 +211,38 @@ describe("schemaChanges", () => {
211
211
  expect(removed.map((f) => f.name)).toEqual(["gone"]);
212
212
  });
213
213
 
214
+ it("a field whose rules moved is changed, with the rules it had", () => {
215
+ const rules = {
216
+ id: "x.y.R",
217
+ versions: [
218
+ {
219
+ version: "v1",
220
+ fields: [
221
+ { name: "code", type: "string", doc: "", rules: [{ name: "min_len", value: "8" }] },
222
+ { name: "who", type: "string", doc: "" },
223
+ { name: "same", type: "string", doc: "", rules: [{ name: "in", value: "a, b" }, { name: "unique" }] },
224
+ { name: "both", type: "int32", doc: "", required: true },
225
+ ],
226
+ },
227
+ {
228
+ version: "v2",
229
+ fields: [
230
+ { name: "code", type: "string", doc: "", rules: [{ name: "min_len", value: "12" }] },
231
+ { name: "who", type: "string", doc: "", required: true, rules: [{ name: "format", value: "uuid" }] },
232
+ // The same rules in another order are the same rules.
233
+ { name: "same", type: "string", doc: "", rules: [{ name: "unique" }, { name: "in", value: "a, b" }] },
234
+ { name: "both", type: "int64", doc: "" },
235
+ ],
236
+ },
237
+ ],
238
+ } as unknown as Event;
239
+ const { byField } = schemaChanges(rules, "v2");
240
+ expect(byField.get("code")).toEqual({ change: "changed", rulesFrom: "≥ 8 chars" });
241
+ expect(byField.get("who")).toEqual({ change: "changed", rulesFrom: "" });
242
+ expect(byField.get("same")).toBeUndefined();
243
+ expect(byField.get("both")).toEqual({ change: "changed", from: "int32", rulesFrom: "required" });
244
+ });
245
+
214
246
  it("agrees with the fixture: OrderPlaced v2 added channel", () => {
215
247
  const placed = eventById("shop.oms.order.OrderPlaced");
216
248
  const { byField, removed } = schemaChanges(placed, "v2");