@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.
- package/README.md +151 -6
- package/catalog/model.go +147 -0
- package/catalog/roundtrip_test.go +7 -0
- package/cli/portolan.mjs +6 -1
- package/go.mod +7 -0
- package/go.sum +20 -6
- package/package.json +10 -9
- package/plugin/describe.go +38 -0
- package/plugins/README.md +296 -5
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +13 -2
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +2 -1
- package/plugins/extract-django/extract_test.py +13 -0
- package/plugins/extract-django/lifecycle.py +2 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +5 -4
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +130 -3
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +880 -13
- package/scripts/README.md +3 -1
- package/scripts/delivery-presets.mjs +36 -3
- package/scripts/gen-likec4.mjs +149 -16
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +30 -3
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +1 -0
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +216 -8
- package/scripts/local-api.test.mjs +88 -2
- package/scripts/local-discovery.mjs +12 -0
- package/scripts/manifest.mjs +10 -1
- package/scripts/plugin-host.mjs +23 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/run-builtin.mjs +26 -5
- package/scripts/schema.mjs +138 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +12 -6
- package/scripts/warning-policy.test.mjs +40 -3
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +246 -1
- package/src/catalog-validation.ts +104 -2
- package/src/catalog.test.ts +40 -1
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.tsx +1 -1
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +21 -0
- package/src/enrich.test.ts +127 -3
- package/src/enrich.ts +104 -6
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +53 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +24 -0
- package/src/landing/LandingPage.tsx +15 -11
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +96 -2
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +9 -0
- package/src/lib/warnings.ts +10 -10
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +173 -9
- package/src/pages/AggregatePage.tsx +57 -7
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +2 -0
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +58 -2
- package/src/pages/Settings.tsx +28 -2
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
package/src/routes.test.ts
CHANGED
|
@@ -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(),
|
package/src/lib/data-problems.ts
DELETED
|
@@ -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
|
-
}
|