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