@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
@@ -27,6 +27,13 @@ describe("routes", () => {
27
27
  expect(isRoutable("/landing/")).toBe(true);
28
28
  });
29
29
 
30
+ it("routes the plugin index, and one plugin on it by manifest name", () => {
31
+ expect(paths.plugins()).toBe("/plugins");
32
+ expect(isRoutable(paths.plugins())).toBe(true);
33
+ expect(paths.plugin("go-nats")).toBe("/plugins#plugin-go-nats");
34
+ expect(isRoutable(paths.plugin("go-nats"))).toBe(true);
35
+ });
36
+
30
37
  it("routes every URL the catalog can produce", () => {
31
38
  const unroutable = allCatalogPaths(catalog).filter((p) => !isRoutable(p));
32
39
  expect(unroutable).toEqual([]);
@@ -169,6 +176,8 @@ describe("routes", () => {
169
176
  expect(paths.settingsProjects()).toBe("/settings/projects");
170
177
  expect(paths.settingsPipeline()).toBe("/settings/pipeline");
171
178
  expect(paths.settingsDelivery()).toBe("/settings/delivery");
179
+ expect(paths.settingsRecordings()).toBe("/settings/recordings");
180
+ expect(isRoutable(paths.settingsRecordings())).toBe(true);
172
181
  expect(paths.settingsIntegrations()).toBe("/settings/integrations");
173
182
  expect(paths.settingsPreferences()).toBe("/settings/preferences");
174
183
  expect(paths.settingsAbout()).toBe("/settings/about");
package/src/routes.ts CHANGED
@@ -39,12 +39,22 @@ export const paths = {
39
39
  * page, and the reader is told what was narrowed and offered it back.
40
40
  */
41
41
  term: (termId: string) => `/language?term=${encodeURIComponent(termId)}`,
42
+ /**
43
+ * Every plugin the package ships, as each describes itself. One page: the
44
+ * question a reader asks is "what can this read?", and the answer is the
45
+ * whole list grouped by what is read, not one plugin at a time.
46
+ */
47
+ plugins: () => "/plugins",
48
+ /** One plugin on that page, by the name the manifest declares it under. */
49
+ plugin: (name: string) => `/plugins#plugin-${name}`,
42
50
  problems: () => "/problems",
43
51
  changes: () => "/changes",
44
52
  settings: () => "/settings",
45
53
  settingsProjects: () => "/settings/projects",
46
54
  settingsPipeline: () => "/settings/pipeline",
47
55
  settingsDelivery: () => "/settings/delivery",
56
+ settingsRecordings: () => "/settings/recordings",
57
+ settingsRules: () => "/settings/rules",
48
58
  settingsIntegrations: () => "/settings/integrations",
49
59
  settingsPreferences: () => "/settings/preferences",
50
60
  settingsAbout: () => "/settings/about",
@@ -195,6 +205,7 @@ export const SERVICE_ANCHOR = {
195
205
  aggregates: "svc-aggregates",
196
206
  events: "svc-events",
197
207
  commands: "svc-commands",
208
+ deployments: "svc-deployments",
198
209
  } as const;
199
210
 
200
211
  /** The section anchors on the context map. */
@@ -211,7 +222,9 @@ export function relationAnchor(relationId: string): string {
211
222
  /** The section anchors on the overview, so "g d" has somewhere to land. */
212
223
  export const OVERVIEW_ANCHOR = {
213
224
  landscape: "landscape",
225
+ deployed: "deployed",
214
226
  contexts: "contexts",
227
+ bridges: "bridges",
215
228
  flows: "flows-by-reach",
216
229
  problems: "problems",
217
230
  } as const;
@@ -262,6 +275,12 @@ export function adrPath(adrId: string): string | null {
262
275
  return adr ? paths.adr(adr.slug) : null;
263
276
  }
264
277
 
278
+ /** Path to a flow's page, or null if the id is not a catalog flow. */
279
+ export function flowPath(flowId: string): string | null {
280
+ const flow = index.catalog.flows.find((candidate) => candidate.id === flowId);
281
+ return flow ? paths.flow(flow.slug) : null;
282
+ }
283
+
265
284
  /** Path to an aggregate page, or null if the id is not a catalog aggregate. */
266
285
  export function aggregatePath(aggregateId: string): string | null {
267
286
  const aggregate = index.aggregateById.get(aggregateId);
@@ -374,9 +393,10 @@ const ROUTES: RegExp[] = [
374
393
  /^\/flows\/[^/]+$/,
375
394
  /^\/adrs$/,
376
395
  /^\/language$/,
396
+ /^\/plugins$/,
377
397
  /^\/problems$/,
378
398
  /^\/changes$/,
379
- /^\/settings(?:\/(?:projects|pipeline|delivery|integrations|preferences|about))?$/,
399
+ /^\/settings(?:\/(?:projects|pipeline|delivery|recordings|rules|integrations|preferences|about))?$/,
380
400
  /^\/externals\/[^/]+$/,
381
401
  /^\/map$/,
382
402
  /^\/adrs\/[^/]+$/,
@@ -408,6 +428,7 @@ export function allCatalogPaths(catalog: Catalog): string[] {
408
428
  paths.map(),
409
429
  paths.adrs(),
410
430
  paths.language(),
431
+ paths.plugins(),
411
432
  paths.problems(),
412
433
  paths.changes(),
413
434
  paths.settings(),
@@ -1,314 +0,0 @@
1
- // Where the schema and the model disagree.
2
- //
3
- // The unresolved edges in derive.ts are about arrows that land nowhere. These
4
- // are about arrows that land somewhere they should not, and about two claims
5
- // in the catalog that cannot both be current. They share one shape and one
6
- // page, because a reader scanning Problems asks the same question of all of
7
- // them: what do I have to go and look at?
8
- //
9
- // Errors are boundary leaks — one service reaching into another's schema. They
10
- // are errors because no amount of context makes them fine: a foreign key across
11
- // a service boundary is a deployment order nobody wrote down, and a second
12
- // writer in someone else's database is a bounded context with two owners.
13
- //
14
- // Warnings are staleness. A table that no longer carries the fields it claims,
15
- // a column whose type has drifted from its field's — both are ordinary during a
16
- // migration, so they are reported and not enforced.
17
-
18
- import type {
19
- Catalog,
20
- CatalogIndex,
21
- Column,
22
- Store,
23
- Table,
24
- } from "../catalog";
25
- import {
26
- aggregateBlocks,
27
- blockFields,
28
- mapsFieldPath,
29
- relationOfColumnId,
30
- storeViews,
31
- viewReads,
32
- } from "../catalog";
33
- import type { Problem } from "./derive";
34
- import { payloadColumn, typesDisagree } from "./data-model";
35
-
36
- /** The context a store sits in, via the service that owns it. */
37
- function contextOf(index: CatalogIndex, serviceId: string): string {
38
- return index.serviceContext.get(serviceId)?.id ?? "";
39
- }
40
-
41
- /**
42
- * Every persistence problem, stores in catalog order and tables in theirs.
43
- * Errors first within each store, so the boundary leaks are not buried under
44
- * a column-by-column type audit.
45
- */
46
- export function dataProblems(
47
- catalog: Catalog,
48
- index: CatalogIndex,
49
- ): Problem[] {
50
- const errors: Problem[] = [];
51
- const warnings: Problem[] = [];
52
-
53
- for (const store of catalog.stores ?? []) {
54
- for (const table of store.tables) {
55
- errors.push(...crossServiceKeys(index, store, table));
56
- errors.push(...sharedStore(index, store, table));
57
- warnings.push(...outboxWithoutPayload(index, store, table));
58
- warnings.push(...drift(catalog, index, store, table));
59
- warnings.push(...typeDrift(catalog, index, store, table));
60
- warnings.push(
61
- ...crossServiceLineage(index, store, table.id, table.columns),
62
- );
63
- }
64
- for (const view of storeViews(store)) {
65
- warnings.push(...crossServiceLineage(index, store, view.id, view.columns));
66
- // A view is defined over what it reads, so a `reads` entry pointing into
67
- // someone else's store is the same coupling stated one level up.
68
- for (const readId of viewReads(view)) {
69
- const target =
70
- index.tableById.get(readId) ?? index.viewById.get(readId);
71
- if (!target || target.store.owner === store.owner) continue;
72
- warnings.push({
73
- kind: "cross-service-lineage",
74
- severity: "warning",
75
- context: contextOf(index, store.owner),
76
- service: store.owner,
77
- id: view.id,
78
- peer: readId,
79
- note: `${view.id} is defined over ${readId}, which ${target.store.owner} owns; a rename over there breaks this view with no error until it is read.`,
80
- source: view.source ?? store.source,
81
- });
82
- }
83
- }
84
- }
85
-
86
- return [...errors, ...warnings];
87
- }
88
-
89
- /**
90
- * A foreign key into a table another service owns. The database will enforce
91
- * it, which is the problem: two services are now one schema, and neither can
92
- * migrate its own table without the other's release.
93
- */
94
- function crossServiceKeys(
95
- index: CatalogIndex,
96
- store: Store,
97
- table: Table,
98
- ): Problem[] {
99
- const out: Problem[] = [];
100
- for (const column of table.columns) {
101
- if (!column.fk) continue;
102
- const target = index.tableById.get(column.fk.table);
103
- if (!target || target.store.owner === store.owner) continue;
104
- out.push({
105
- kind: "cross-service-fk",
106
- severity: "error",
107
- context: contextOf(index, store.owner),
108
- service: store.owner,
109
- id: `${table.id}.${column.name}`,
110
- peer: column.fk.table,
111
- note: `${store.owner} holds a foreign key into a table owned by ${target.store.owner}; neither service can migrate that table alone.`,
112
- source: store.source,
113
- });
114
- }
115
- return out;
116
- }
117
-
118
- /**
119
- * A column copied from another service's schema.
120
- *
121
- * Not an error: copying is how a projection is built, and the alternative — a
122
- * foreign key — is worse. It is a warning because the coupling is invisible
123
- * from the other side. Nothing in the source database records that someone
124
- * else's column is a copy of it, so the rename that breaks this is a rename
125
- * that looked safe.
126
- */
127
- function crossServiceLineage(
128
- index: CatalogIndex,
129
- store: Store,
130
- relationId: string,
131
- columns: Column[],
132
- ): Problem[] {
133
- const out: Problem[] = [];
134
- const seen = new Set<string>();
135
- for (const column of columns) {
136
- for (const ref of column.from ?? []) {
137
- const source = relationOfColumnId(ref);
138
- const target =
139
- index.tableById.get(source) ?? index.viewById.get(source);
140
- if (!target || target.store.owner === store.owner) continue;
141
- if (seen.has(ref)) continue;
142
- seen.add(ref);
143
- out.push({
144
- kind: "cross-service-lineage",
145
- severity: "warning",
146
- context: contextOf(index, store.owner),
147
- service: store.owner,
148
- id: `${relationId}.${column.name}`,
149
- peer: ref,
150
- note: `${store.owner} copies this value from ${ref}, which ${target.store.owner} owns; nothing on that side records that the copy exists.`,
151
- source: store.source,
152
- });
153
- }
154
- }
155
- return out;
156
- }
157
-
158
- /**
159
- * A table in one service's store that holds another service's aggregate. An
160
- * aggregate is written by the service that owns it, so this is a second writer
161
- * in someone else's database — reading is allowed and is drawn ghosted, but
162
- * this is not reading.
163
- */
164
- function sharedStore(
165
- index: CatalogIndex,
166
- store: Store,
167
- table: Table,
168
- ): Problem[] {
169
- const aggregateId = table.persists?.aggregate;
170
- if (!aggregateId) return [];
171
- // A projection is a COPY of someone else's aggregate, maintained locally
172
- // from their events. That is the pattern this rule exists to make room for,
173
- // not the one it is looking for.
174
- if (table.role === "projection") return [];
175
- const writer = index.aggregateOwner.get(aggregateId);
176
- if (!writer || writer.id === store.owner) return [];
177
- return [
178
- {
179
- kind: "shared-store",
180
- severity: "error",
181
- context: contextOf(index, store.owner),
182
- service: store.owner,
183
- id: table.id,
184
- peer: writer.id,
185
- note: `${store.id} is owned by ${store.owner}, but this table holds ${aggregateId}, which ${writer.id} writes.`,
186
- source: store.source,
187
- },
188
- ];
189
- }
190
-
191
- /**
192
- * An outbox with nothing to deliver. The whole point of the pattern is that the
193
- * event body is committed in the same transaction as the state change, so an
194
- * outbox with no payload column is either not an outbox or not finished.
195
- */
196
- function outboxWithoutPayload(
197
- index: CatalogIndex,
198
- store: Store,
199
- table: Table,
200
- ): Problem[] {
201
- if (table.role !== "outbox" || payloadColumn(table)) return [];
202
- return [
203
- {
204
- kind: "outbox-payload",
205
- severity: "warning",
206
- context: contextOf(index, store.owner),
207
- service: store.owner,
208
- id: table.id,
209
- peer: store.id,
210
- note: "an outbox carries the event body in the same transaction as the state change; this table declares no json column to hold one",
211
- source: store.source,
212
- },
213
- ];
214
- }
215
-
216
- /**
217
- * A table that claims an aggregate none of its columns carry. Either the table
218
- * was relabelled and the columns were not, or the aggregate moved on without
219
- * it; both leave the `persists` claim asserting more than it can back up.
220
- */
221
- function drift(
222
- catalog: Catalog,
223
- index: CatalogIndex,
224
- store: Store,
225
- table: Table,
226
- ): Problem[] {
227
- const aggregateId = table.persists?.aggregate;
228
- if (!aggregateId) return [];
229
- // A derived table holds a copy shaped for reading, not the aggregate's own
230
- // fields, so it has nothing to have drifted FROM. Only a table that claims
231
- // to BE where the aggregate lives can contradict it.
232
- if (table.role === "projection" || table.role === "outbox") return [];
233
- const aggregate = index.aggregateById.get(aggregateId);
234
- if (!aggregate) return [];
235
-
236
- const declared = new Set<string>();
237
- for (const { block } of aggregateBlocks(aggregate)) {
238
- for (const field of blockFields(catalog, block)) declared.add(field.name);
239
- }
240
- // Nothing to drift from: an aggregate whose blocks are known by name only
241
- // cannot contradict a schema.
242
- if (declared.size === 0) return [];
243
-
244
- const mapped = table.columns.filter((c) => {
245
- if (!c.maps) return false;
246
- const head = mapsFieldPath(c.maps).split(".")[0] ?? "";
247
- return declared.has(head);
248
- });
249
- if (mapped.length > 0) return [];
250
-
251
- const claimed = table.columns.filter((c) => c.maps).length;
252
- return [
253
- {
254
- kind: "persistence-drift",
255
- severity: "warning",
256
- context: contextOf(index, store.owner),
257
- service: store.owner,
258
- id: table.id,
259
- peer: aggregateId,
260
- note:
261
- claimed === 0
262
- ? `no column of ${table.name} maps to a field of ${aggregateId}`
263
- : `${claimed} column(s) of ${table.name} map to fields ${aggregateId} no longer declares`,
264
- source: store.source,
265
- },
266
- ];
267
- }
268
-
269
- /**
270
- * A column and the field it carries that no longer agree on a type. One of the
271
- * two has been changed and the other has not; which one is the reader's call,
272
- * so the problem names both and stops there.
273
- */
274
- function typeDrift(
275
- catalog: Catalog,
276
- index: CatalogIndex,
277
- store: Store,
278
- table: Table,
279
- ): Problem[] {
280
- const out: Problem[] = [];
281
- const aggregateId = table.persists?.aggregate;
282
- const aggregate = aggregateId
283
- ? index.aggregateById.get(aggregateId)
284
- : undefined;
285
- if (!aggregate) return out;
286
-
287
- const fields = new Map<string, string>();
288
- for (const { block } of aggregateBlocks(aggregate)) {
289
- for (const field of blockFields(catalog, block)) {
290
- // First declaration wins: two blocks of one aggregate naming the same
291
- // field agree about it far more often than not, and a column mapping to
292
- // "Id" is not made clearer by picking the second one.
293
- if (!fields.has(field.name)) fields.set(field.name, field.type);
294
- }
295
- }
296
-
297
- for (const column of table.columns) {
298
- if (!column.maps) continue;
299
- const head = mapsFieldPath(column.maps).split(".")[0] ?? "";
300
- const domainType = fields.get(head);
301
- if (!domainType || !typesDisagree(column.type, domainType)) continue;
302
- out.push({
303
- kind: "column-type",
304
- severity: "warning",
305
- context: contextOf(index, store.owner),
306
- service: store.owner,
307
- id: `${table.id}.${column.name}`,
308
- peer: column.maps,
309
- note: `column is ${column.type}, ${column.maps} is ${domainType}`,
310
- source: store.source,
311
- });
312
- }
313
- return out;
314
- }
@@ -1,237 +0,0 @@
1
- // Where a call and the interface it names disagree.
2
- //
3
- // docs/adr/org.0001.md promises this: producers publish their schema, consumers
4
- // keep a narrowed copy, and "a field, method or enum value that differs between
5
- // the two is reported against the consuming service". The extractor retains
6
- // those copies on the consumer; this reader compares them after every source
7
- // has met in the merged catalog.
8
- //
9
- // It is deliberately NOT done in the merge. src/merge.ts is documented as
10
- // union-by-id plus first-non-empty and nothing else, and teaching it to compare
11
- // shapes would put a semantic judgement in the one place that has none. The
12
- // comparison belongs here, over the merged catalog, on the page where the rest
13
- // of that judgement already lives.
14
- //
15
- // This is also NOT the same finding as derive.ts's `rpc` problem. That one is
16
- // about the PEER: a call whose other end is nobody the catalog knows. This one
17
- // is about a call whose peer is known and whose METHOD is not - the copy is
18
- // stale, or the producer removed something, and the two are different repairs.
19
-
20
- import type {
21
- Catalog,
22
- CatalogIndex,
23
- RpcEnum,
24
- RpcMessage,
25
- RpcMethod,
26
- RpcService,
27
- Service,
28
- } from "../catalog";
29
- import type { Problem } from "./derive";
30
-
31
- /**
32
- * Every missing method and every retained consumer descriptor that disagrees
33
- * with the provider's method, field, or enum value.
34
- *
35
- * An unresolved call is skipped: it already has a problem of its own from
36
- * `problems()`, and reporting the same edge twice under two headings would make
37
- * the page look worse than the estate is.
38
- */
39
- export function protoProblems(
40
- catalog: Catalog,
41
- index: CatalogIndex,
42
- ): Problem[] {
43
- const out: Problem[] = [];
44
-
45
- for (const context of catalog.contexts) {
46
- for (const service of context.services) {
47
- for (const call of service.consumes) {
48
- if (call.status === "unresolved") continue;
49
-
50
- // The peer has to be a service in the catalog before the method is
51
- // worth asking about. A call to something outside the estate is a fact,
52
- // not a defect.
53
- if (!index.serviceById.has(call.peer)) continue;
54
-
55
- if (index.rpcProviderByMethod.has(call.id)) continue;
56
-
57
- out.push({
58
- kind: "proto-missing",
59
- // An error, not a staleness warning: the peer is in the catalog and
60
- // does not answer on this. Either the copy is behind or the method is
61
- // gone, and both are a call that will fail.
62
- severity: "error",
63
- context: context.id,
64
- service: service.id,
65
- id: call.id,
66
- peer: call.peer,
67
- note: call.note,
68
- source: call.source,
69
- });
70
- }
71
-
72
- for (const copy of service.copies ?? []) {
73
- const drift = driftAgainstProvider(service, copy, index);
74
- if (drift) out.push(drift);
75
- }
76
- }
77
- }
78
-
79
- return out;
80
- }
81
-
82
- /** Compare one consumer copy only when one of its calls resolves to a provider. */
83
- function driftAgainstProvider(
84
- consumer: Service,
85
- copy: RpcService,
86
- index: CatalogIndex,
87
- ): Problem | undefined {
88
- const calls = new Map(
89
- consumer.consumes
90
- .filter((call) => call.id.startsWith(`${copy.id}/`))
91
- .map((call) => [call.id, call]),
92
- );
93
- const resolved = copy.methods
94
- .map((method) => calls.get(`${copy.id}/${method.name}`))
95
- .find(
96
- (call) =>
97
- call?.status !== "unresolved" && index.serviceById.has(call?.peer ?? ""),
98
- );
99
- if (!resolved) return undefined;
100
-
101
- const providerService = index.serviceById.get(resolved.peer);
102
- const provider = providerService?.provides.find(
103
- (candidate) => candidate.id === copy.id,
104
- );
105
- if (!providerService || !provider) return undefined;
106
-
107
- const differences = compareInterfaces(copy, provider);
108
- if (differences.length === 0) return undefined;
109
-
110
- return {
111
- kind: "proto-drift",
112
- severity: "warning",
113
- context: index.serviceContext.get(consumer.id)?.id ?? "",
114
- service: consumer.id,
115
- id: copy.id,
116
- peer: providerService.id,
117
- note: `Vendored copy differs from ${providerService.id}: ${differences.join("; ")}.`,
118
- source: copy.source,
119
- };
120
- }
121
-
122
- function compareInterfaces(copy: RpcService, provider: RpcService): string[] {
123
- const out: string[] = [];
124
- const methods = new Map(provider.methods.map((method) => [method.name, method]));
125
-
126
- for (const method of copy.methods) {
127
- const actual = methods.get(method.name);
128
- // A missing method already has the stronger proto-missing error.
129
- if (!actual) continue;
130
- compareMethod(method, actual, out);
131
- }
132
-
133
- const messages = new Map(
134
- (provider.messages ?? []).map((message) => [message.name, message]),
135
- );
136
- for (const message of copy.messages ?? []) {
137
- compareMessage(message, messages.get(message.name), out);
138
- }
139
-
140
- const enums = new Map(
141
- (provider.enums ?? []).map((item) => [item.name, item]),
142
- );
143
- for (const item of copy.enums ?? []) {
144
- compareEnum(item, enums.get(item.name), out);
145
- }
146
-
147
- return out;
148
- }
149
-
150
- function compareMethod(
151
- copy: RpcMethod,
152
- provider: RpcMethod,
153
- out: string[],
154
- ): void {
155
- for (const side of ["request", "response"] as const) {
156
- if (copy[side] !== provider[side]) {
157
- out.push(
158
- `${copy.name} ${side} is ${copy[side] || "unnamed"}, provider has ${provider[side] || "unnamed"}`,
159
- );
160
- }
161
- }
162
- if (copy.streaming !== provider.streaming) {
163
- out.push(
164
- `${copy.name} streaming is ${copy.streaming ?? "unary"}, provider has ${provider.streaming ?? "unary"}`,
165
- );
166
- }
167
- }
168
-
169
- /**
170
- * A narrowed copy may omit provider fields it does not read. Every field it
171
- * does carry must still have the provider's type and protobuf number.
172
- */
173
- function compareMessage(
174
- copy: RpcMessage,
175
- provider: RpcMessage | undefined,
176
- out: string[],
177
- ): void {
178
- if (!provider) {
179
- out.push(`message ${copy.name} is absent from the provider`);
180
- return;
181
- }
182
-
183
- const fields = new Map(provider.fields.map((field) => [field.name, field]));
184
- for (const field of copy.fields) {
185
- const actual = fields.get(field.name);
186
- if (!actual) {
187
- out.push(`${copy.name}.${field.name} is absent from the provider`);
188
- continue;
189
- }
190
- if (field.type !== actual.type) {
191
- out.push(
192
- `${copy.name}.${field.name} is ${field.type}, provider has ${actual.type}`,
193
- );
194
- }
195
- if (
196
- field.number !== undefined &&
197
- actual.number !== undefined &&
198
- field.number !== actual.number
199
- ) {
200
- out.push(
201
- `${copy.name}.${field.name} is field ${field.number}, provider has ${actual.number}`,
202
- );
203
- }
204
- }
205
- }
206
-
207
- /** Enum names and numbers are both wire claims, so the sets must match whole. */
208
- function compareEnum(
209
- copy: RpcEnum,
210
- provider: RpcEnum | undefined,
211
- out: string[],
212
- ): void {
213
- if (!provider) {
214
- out.push(`enum ${copy.name} is absent from the provider`);
215
- return;
216
- }
217
-
218
- const copyByName = new Map(copy.values.map((value) => [value.name, value]));
219
- const providerByName = new Map(
220
- provider.values.map((value) => [value.name, value]),
221
- );
222
- for (const value of copy.values) {
223
- const actual = providerByName.get(value.name);
224
- if (!actual) {
225
- out.push(`${copy.name}.${value.name} is absent from the provider`);
226
- } else if (value.number !== actual.number) {
227
- out.push(
228
- `${copy.name}.${value.name} is ${value.number}, provider has ${actual.number}`,
229
- );
230
- }
231
- }
232
- for (const value of provider.values) {
233
- if (!copyByName.has(value.name)) {
234
- out.push(`${copy.name}.${value.name} is missing from the copy`);
235
- }
236
- }
237
- }