@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
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
TABLE_ROLES,
|
|
21
21
|
aggregateBlocks,
|
|
22
22
|
allAggregates,
|
|
23
|
+
allDeployments,
|
|
23
24
|
allExternals,
|
|
24
25
|
allModules,
|
|
25
26
|
allRepos,
|
|
@@ -165,6 +166,20 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
165
166
|
),
|
|
166
167
|
);
|
|
167
168
|
const storeIds = new Set(allStores(catalog).map((store) => store.id));
|
|
169
|
+
// `<service>|<aggregate id>/<operation id>`: what a `call` step into a
|
|
170
|
+
// service may name - the use case it runs, when the flow crosses a
|
|
171
|
+
// boundary the transport does not draw (an in-process command bus).
|
|
172
|
+
const operationRefs = new Set(
|
|
173
|
+
catalog.contexts.flatMap((context) =>
|
|
174
|
+
context.services.flatMap((service) =>
|
|
175
|
+
service.aggregates.flatMap((aggregate) =>
|
|
176
|
+
aggregate.operations.map(
|
|
177
|
+
(operation) => `${service.id}|${aggregate.id}/${operation.id}`,
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
);
|
|
168
183
|
|
|
169
184
|
assertUniqueSlugs(
|
|
170
185
|
catalog.contexts.map((c) => c.id),
|
|
@@ -253,6 +268,27 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
253
268
|
}
|
|
254
269
|
technologies.add(technology);
|
|
255
270
|
}
|
|
271
|
+
// Hosts and dials are names, and a name is checked the way a handle
|
|
272
|
+
// is: not blank, not listed twice. Two services behind one Ingress
|
|
273
|
+
// host is one host on two pages, which is what the manifests say.
|
|
274
|
+
for (const field of ["hosts", "dials"] as const) {
|
|
275
|
+
const names = new Set<string>();
|
|
276
|
+
for (const name of service[field] ?? []) {
|
|
277
|
+
if (!name.trim()) {
|
|
278
|
+
fail(
|
|
279
|
+
`service "${service.id}" has a ${field.slice(0, -1)} with no name`,
|
|
280
|
+
`service ${service.id}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
if (names.has(name)) {
|
|
284
|
+
fail(
|
|
285
|
+
`service "${service.id}" names ${field.slice(0, -1)} "${name}" twice`,
|
|
286
|
+
`service ${service.id}`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
names.add(name);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
256
292
|
for (const call of service.consumes) rpcIds.add(call.id);
|
|
257
293
|
for (const provided of service.provides) {
|
|
258
294
|
for (const method of provided.methods) {
|
|
@@ -366,6 +402,14 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
366
402
|
);
|
|
367
403
|
}
|
|
368
404
|
}
|
|
405
|
+
for (const field of operation.fields ?? []) {
|
|
406
|
+
if (field.ref !== undefined && !(field.ref in catalog.defs)) {
|
|
407
|
+
fail(
|
|
408
|
+
`field "${field.name}" of operation "${operation.id}" of aggregate "${aggregate.id}" references unknown def "${field.ref}"`,
|
|
409
|
+
`aggregate ${aggregate.id} / operation ${operation.id} / field ${field.name}`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
369
413
|
}
|
|
370
414
|
validateBlocks(catalog, aggregate);
|
|
371
415
|
assertUniqueSlugs(
|
|
@@ -622,16 +666,47 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
622
666
|
eventIds.has(step.ref) ||
|
|
623
667
|
rpcIds.has(step.ref) ||
|
|
624
668
|
(step.kind === "rpc" &&
|
|
625
|
-
providedRpcRefs.has(`${step.to}|${step.ref}`))
|
|
669
|
+
providedRpcRefs.has(`${step.to}|${step.ref}`)) ||
|
|
670
|
+
(step.kind === "call" &&
|
|
671
|
+
operationRefs.has(`${step.to}|${step.ref}`));
|
|
626
672
|
if (!resolves) {
|
|
627
673
|
fail(
|
|
628
|
-
`flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event, an RpcCall
|
|
674
|
+
`flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event, an RpcCall, a method provided by "${step.to}" nor an operation of "${step.to}", and status is "${step.status}" rather than "unresolved"`,
|
|
629
675
|
`flow ${flow.id} / step ${step.id}`,
|
|
630
676
|
);
|
|
631
677
|
}
|
|
632
678
|
}
|
|
633
679
|
}
|
|
634
680
|
|
|
681
|
+
// An example names the steps it showed by id; one that names a step
|
|
682
|
+
// the flow does not have is a recording of some other flow, or of this
|
|
683
|
+
// one before it changed, and either way it would be shown next to the
|
|
684
|
+
// wrong step.
|
|
685
|
+
const exampleIds = new Set<string>();
|
|
686
|
+
for (const example of flow.examples ?? []) {
|
|
687
|
+
if (!example.id || !example.recording || !example.traceId) {
|
|
688
|
+
fail(
|
|
689
|
+
`flow "${flow.slug}" has an example with no id, recording or trace id`,
|
|
690
|
+
`flow ${flow.id}`,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
if (exampleIds.has(example.id)) {
|
|
694
|
+
fail(
|
|
695
|
+
`flow "${flow.slug}" carries example "${example.id}" twice`,
|
|
696
|
+
`flow ${flow.id}`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
exampleIds.add(example.id);
|
|
700
|
+
for (const shown of example.steps) {
|
|
701
|
+
if (!stepById.has(shown.step)) {
|
|
702
|
+
fail(
|
|
703
|
+
`flow "${flow.slug}" example "${example.id}" shows unknown step "${shown.step}"`,
|
|
704
|
+
`flow ${flow.id}`,
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
635
710
|
for (const step of steps) {
|
|
636
711
|
if (step.kind !== "response") {
|
|
637
712
|
if (step.replyTo !== undefined) {
|
|
@@ -670,10 +745,37 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
670
745
|
validateAdrs(catalog, eventIds);
|
|
671
746
|
validateTerms(catalog);
|
|
672
747
|
validateRepos(catalog);
|
|
748
|
+
validateDeployments(catalog);
|
|
673
749
|
|
|
674
750
|
return catalog;
|
|
675
751
|
}
|
|
676
752
|
|
|
753
|
+
/**
|
|
754
|
+
* A deployment names an Application, and names it once.
|
|
755
|
+
*
|
|
756
|
+
* What is not checked is deliberate, and the same omission `validateRepos`
|
|
757
|
+
* makes: a deployment of a repository no service claims is NOT an error. The
|
|
758
|
+
* snapshot lists what the control plane manages, and an Application for a
|
|
759
|
+
* service nobody has read yet, or for something that is not a service at
|
|
760
|
+
* all - a monitoring stack, an ingress controller - is the ordinary case.
|
|
761
|
+
* It simply matches nothing on a service page. Nor is a row with an empty
|
|
762
|
+
* environment or repository: the snapshot says what the deployer said, and
|
|
763
|
+
* a build must not go red because one Application was written oddly.
|
|
764
|
+
*/
|
|
765
|
+
function validateDeployments(catalog: Catalog): void {
|
|
766
|
+
const seen = new Set<string>();
|
|
767
|
+
|
|
768
|
+
for (const deployment of allDeployments(catalog)) {
|
|
769
|
+
const where = `deployment ${deployment.id || "?"}`;
|
|
770
|
+
if (!deployment.id) fail("a deployment has no id", where);
|
|
771
|
+
if (!deployment.name) fail(`deployment "${deployment.id}" names no application`, where);
|
|
772
|
+
if (seen.has(deployment.id)) {
|
|
773
|
+
fail(`deployment "${deployment.id}" is listed twice in one catalog`, where);
|
|
774
|
+
}
|
|
775
|
+
seen.add(deployment.id);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
677
779
|
/**
|
|
678
780
|
* A module reference may only point at a module that exists.
|
|
679
781
|
*
|
|
@@ -1352,6 +1454,15 @@ function validateBlocks(catalog: Catalog, aggregate: Aggregate): void {
|
|
|
1352
1454
|
|
|
1353
1455
|
validateEnums(aggregate);
|
|
1354
1456
|
|
|
1457
|
+
if (aggregate.kind !== undefined && aggregate.kind !== "model-group") {
|
|
1458
|
+
fail(`aggregate "${aggregate.id}" has an unknown kind`, `aggregate ${aggregate.id}`);
|
|
1459
|
+
}
|
|
1460
|
+
if (aggregate.kind === "model-group") {
|
|
1461
|
+
if (aggregate.root !== "" || aggregate.lifecycle) {
|
|
1462
|
+
fail(`model group "${aggregate.id}" cannot declare an aggregate root or lifecycle`, `aggregate ${aggregate.id}`);
|
|
1463
|
+
}
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1355
1466
|
if (!aggregate.root) {
|
|
1356
1467
|
fail(
|
|
1357
1468
|
`aggregate "${aggregate.id}" names no root entity`,
|
package/src/catalog.test.ts
CHANGED
|
@@ -86,6 +86,22 @@ describe("validateCatalog", () => {
|
|
|
86
86
|
expect(() => validateCatalog(bad)).toThrow(/expected 1.1 or 1.2/);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
it("rejects an example that shows a step the flow does not have, and one carried twice", () => {
|
|
90
|
+
const bad = clone();
|
|
91
|
+
const checkout = bad.flows.find((f) => f.slug === "checkout") as Flow;
|
|
92
|
+
const first = walkSteps(checkout.steps)[0];
|
|
93
|
+
if (!first) throw new Error("fixture has no steps");
|
|
94
|
+
const example = { id: "t.jsonl#1", recording: "t.jsonl", traceId: "1", durationMs: 1, steps: [{ step: first.id, durationMs: 1 }] };
|
|
95
|
+
checkout.examples = [example];
|
|
96
|
+
expect(() => validateCatalog(bad)).not.toThrow();
|
|
97
|
+
|
|
98
|
+
checkout.examples = [{ ...example, steps: [{ step: "nope", durationMs: 1 }] }];
|
|
99
|
+
expect(() => validateCatalog(bad)).toThrowError(/flow "checkout" example "t.jsonl#1" shows unknown step "nope"/);
|
|
100
|
+
|
|
101
|
+
checkout.examples = [example, example];
|
|
102
|
+
expect(() => validateCatalog(bad)).toThrowError(/carries example "t.jsonl#1" twice/);
|
|
103
|
+
});
|
|
104
|
+
|
|
89
105
|
it("rejects a step whose participant was never declared, naming flow and step", () => {
|
|
90
106
|
const bad = clone();
|
|
91
107
|
const checkout = bad.flows.find((f) => f.slug === "checkout") as Flow;
|
|
@@ -105,12 +121,35 @@ describe("validateCatalog", () => {
|
|
|
105
121
|
if (!step) throw new Error("fixture has no steps");
|
|
106
122
|
step.ref = "shop.oms.order.NoSuchEvent";
|
|
107
123
|
expect(() => validateCatalog(bad)).toThrowError(
|
|
108
|
-
/resolves to neither an Event, an RpcCall
|
|
124
|
+
/resolves to neither an Event, an RpcCall, a method/,
|
|
109
125
|
);
|
|
110
126
|
step.status = "unresolved";
|
|
111
127
|
expect(() => validateCatalog(bad)).not.toThrow();
|
|
112
128
|
});
|
|
113
129
|
|
|
130
|
+
// A flow that crosses a boundary the transport does not draw - a command
|
|
131
|
+
// handed to another context over an in-process bus - names the use case
|
|
132
|
+
// it runs, and only a `call` step into that use case's service may.
|
|
133
|
+
it("accepts a call step that references an operation of the service it enters", () => {
|
|
134
|
+
const good = clone();
|
|
135
|
+
const service = good.contexts[0]?.services[0];
|
|
136
|
+
const aggregate = service?.aggregates[0];
|
|
137
|
+
const operation = aggregate?.operations[0];
|
|
138
|
+
const flow = good.flows[0] as Flow;
|
|
139
|
+
const step = walkSteps(flow.steps)[0];
|
|
140
|
+
if (!service || !aggregate || !operation || !step) throw new Error("nothing to reference");
|
|
141
|
+
if (!flow.participants.some((p) => p.id === service.id)) {
|
|
142
|
+
flow.participants.push({ id: service.id, kind: "service", context: good.contexts[0]!.id });
|
|
143
|
+
}
|
|
144
|
+
step.kind = "call";
|
|
145
|
+
step.to = service.id;
|
|
146
|
+
step.ref = `${aggregate.id}/${operation.id}`;
|
|
147
|
+
expect(() => validateCatalog(good)).not.toThrow();
|
|
148
|
+
|
|
149
|
+
step.kind = "rpc";
|
|
150
|
+
expect(() => validateCatalog(good)).toThrowError(/nor an operation of/);
|
|
151
|
+
});
|
|
152
|
+
|
|
114
153
|
it("rejects a field ref that is not a defs key", () => {
|
|
115
154
|
const bad = clone();
|
|
116
155
|
const field =
|
package/src/chat/Starter.tsx
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { KindIcon } from "../components/kind";
|
|
8
8
|
import type { Kind } from "../lib/kinds";
|
|
9
|
-
import {
|
|
9
|
+
import { activeCatalogDocs, catalog } from "../data";
|
|
10
10
|
import type { ChatPageContext } from "./page-context";
|
|
11
11
|
import { contextQuestions } from "./page-context";
|
|
12
12
|
|
|
@@ -36,8 +36,6 @@ function examples(): Example[] {
|
|
|
36
36
|
return out;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const base = import.meta.env.BASE_URL;
|
|
40
|
-
|
|
41
39
|
export function Starter({
|
|
42
40
|
onAsk,
|
|
43
41
|
page,
|
|
@@ -51,10 +49,6 @@ export function Starter({
|
|
|
51
49
|
question,
|
|
52
50
|
}))
|
|
53
51
|
: examples();
|
|
54
|
-
const profileSuffix =
|
|
55
|
-
activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
|
|
56
|
-
const docsRoot = `${base}docs/${profileSuffix}`;
|
|
57
|
-
const indexHref = profileSuffix ? `${docsRoot}llms.txt` : `${base}llms.txt`;
|
|
58
52
|
|
|
59
53
|
return (
|
|
60
54
|
<div className="pt-1">
|
|
@@ -79,10 +73,10 @@ export function Starter({
|
|
|
79
73
|
<div className="mono truncate text-muted" title={page.id}>{page.id}</div>
|
|
80
74
|
</div>
|
|
81
75
|
</div>
|
|
82
|
-
<div className="mono mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-line pt-2 text-muted">
|
|
76
|
+
{activeCatalogDocs ? <div className="mono mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-line pt-2 text-muted">
|
|
83
77
|
<span className="text-faint">model context</span>
|
|
84
78
|
<a
|
|
85
|
-
href={
|
|
79
|
+
href={activeCatalogDocs.index}
|
|
86
80
|
target="_blank"
|
|
87
81
|
rel="noreferrer"
|
|
88
82
|
className="rounded-control text-accent hover:underline"
|
|
@@ -91,7 +85,7 @@ export function Starter({
|
|
|
91
85
|
</a>
|
|
92
86
|
{page.docPath ? (
|
|
93
87
|
<a
|
|
94
|
-
href={`${
|
|
88
|
+
href={`${activeCatalogDocs.pages}${page.docPath}`}
|
|
95
89
|
target="_blank"
|
|
96
90
|
rel="noreferrer"
|
|
97
91
|
className="max-w-full truncate rounded-control text-accent hover:underline"
|
|
@@ -100,7 +94,7 @@ export function Starter({
|
|
|
100
94
|
current catalog page
|
|
101
95
|
</a>
|
|
102
96
|
) : null}
|
|
103
|
-
</div>
|
|
97
|
+
</div> : null}
|
|
104
98
|
</div>
|
|
105
99
|
) : null}
|
|
106
100
|
<div className="label mt-5 mb-1">try one</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
2
|
+
import { catalogDocs } from "../catalog-docs";
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.unstubAllGlobals();
|
|
6
|
+
vi.doUnmock("../data");
|
|
7
|
+
vi.resetModules();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it.each([
|
|
11
|
+
{ id: "default", generate: [{ plugin: "markdown", out: "docs" }], index: "/portolan/llms.txt", pages: "/portolan/docs/" },
|
|
12
|
+
{ id: "example", generate: [
|
|
13
|
+
{ plugin: "markdown", catalog: "portolan", out: "docs" },
|
|
14
|
+
{ plugin: "markdown", catalog: "example", out: "docs/example" },
|
|
15
|
+
], index: "/portolan/docs/example/llms.txt", pages: "/portolan/docs/example/" },
|
|
16
|
+
])("loads the index and catalog pages for $id", async ({ id, generate, index, pages }) => {
|
|
17
|
+
vi.doMock("../data", () => ({ activeCatalogDocs: catalogDocs({ generate }, id, "/portolan/") }));
|
|
18
|
+
const fetcher = vi.fn().mockResolvedValue(new Response("# Catalog documentation"));
|
|
19
|
+
vi.stubGlobal("fetch", fetcher);
|
|
20
|
+
const tools = await import("./tools");
|
|
21
|
+
|
|
22
|
+
expect(await tools.loadIndex()).toBe("# Catalog documentation");
|
|
23
|
+
expect(fetcher).toHaveBeenLastCalledWith(index);
|
|
24
|
+
fetcher.mockResolvedValue(new Response("# Auth service"));
|
|
25
|
+
expect(await tools.readPage("docs/auth/README.md")).toBe("# Auth service");
|
|
26
|
+
expect(fetcher).toHaveBeenLastCalledWith(`${pages}auth/README.md`);
|
|
27
|
+
});
|
package/src/chat/tools.ts
CHANGED
|
@@ -6,19 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { jsonSchema, tool } from "ai";
|
|
8
8
|
import { clipPage, pagePath, TOOL_SPECS } from "./prompt";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
const base = import.meta.env.BASE_URL;
|
|
12
|
-
const profileSuffix =
|
|
13
|
-
activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
|
|
14
|
-
const docsBase = `${base}docs/${profileSuffix}`;
|
|
15
|
-
const indexUrl = profileSuffix ? `${docsBase}llms.txt` : `${base}llms.txt`;
|
|
9
|
+
import { activeCatalogDocs } from "../data";
|
|
16
10
|
|
|
17
11
|
/** One page of the generated docs, as the model gets it. */
|
|
18
12
|
export async function readPage(raw: unknown): Promise<string> {
|
|
19
13
|
const path = pagePath(raw);
|
|
20
14
|
if (!path) return "That is not a page of the catalog. Use a path from the index.";
|
|
21
|
-
|
|
15
|
+
if (!activeCatalogDocs) return "No documentation is configured for this catalog.";
|
|
16
|
+
const response = await fetch(`${activeCatalogDocs.pages}${path.slice("docs/".length)}`);
|
|
22
17
|
if (!response.ok) return `No page at ${path}. Use a path from the index.`;
|
|
23
18
|
return clipPage(await response.text());
|
|
24
19
|
}
|
|
@@ -26,7 +21,8 @@ export async function readPage(raw: unknown): Promise<string> {
|
|
|
26
21
|
/** llms.txt, once per session. */
|
|
27
22
|
let indexPromise: Promise<string> | null = null;
|
|
28
23
|
export function loadIndex(): Promise<string> {
|
|
29
|
-
|
|
24
|
+
if (!activeCatalogDocs) return Promise.reject(new Error("No documentation is configured for this catalog."));
|
|
25
|
+
indexPromise ??= fetch(activeCatalogDocs.index).then((response) => {
|
|
30
26
|
if (!response.ok) {
|
|
31
27
|
indexPromise = null;
|
|
32
28
|
throw new Error("the catalog index (llms.txt) could not be read");
|
|
@@ -6,23 +6,25 @@ import { absoluteTime, plural, relativeTime } from "../lib/format";
|
|
|
6
6
|
/**
|
|
7
7
|
* Where the catalog on this page came from, and when.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* A source is dated by the commit that last changed it, read off the
|
|
10
|
+
* checkout's history rather than out of the file (portolan.0010). The header
|
|
11
|
+
* says one date and one commit, and both are summaries of a corpus: the date
|
|
12
|
+
* is the OLDEST of the sources, because a merged catalog is exactly as fresh
|
|
13
|
+
* as its stalest part, and the commit is a count whenever the sources do not
|
|
14
|
+
* agree on one. A summary of many numbers is the right thing to put in a
|
|
15
|
+
* header and the wrong thing to leave a reader with - "6 sources · 15 hours
|
|
14
16
|
* ago" answers "is this current" and refuses "which part is not", which is the
|
|
15
17
|
* question anyone who did not like the first answer asks next.
|
|
16
18
|
*
|
|
17
19
|
* So the stamp opens. Behind it is one row per commit, newest first, and the
|
|
18
20
|
* row the header is quoting says so. Rows are per COMMIT and not per file
|
|
19
|
-
* because twenty-eight fragments
|
|
21
|
+
* because twenty-eight fragments changed by one commit are one source of
|
|
20
22
|
* facts, and it is the same count the header prints.
|
|
21
23
|
*/
|
|
22
24
|
export function CatalogStamp() {
|
|
23
25
|
const groups = byCommit(catalogSources);
|
|
24
26
|
const authored = catalogSources.filter((source) => !source.commit);
|
|
25
|
-
const summary = `catalog
|
|
27
|
+
const summary = `catalog last changed ${absoluteTime(catalog.generatedAt)}, ${catalog.commit}`;
|
|
26
28
|
|
|
27
29
|
return (
|
|
28
30
|
<Popover className="ml-auto">
|
|
@@ -43,7 +45,7 @@ export function CatalogStamp() {
|
|
|
43
45
|
>
|
|
44
46
|
<div className="label mb-1.5 px-1">
|
|
45
47
|
{groups.length} {plural(groups.length, "source")}, by the commit each
|
|
46
|
-
|
|
48
|
+
last changed in
|
|
47
49
|
</div>
|
|
48
50
|
<dl className="mono grid grid-cols-[auto_1fr_auto] items-baseline gap-x-3 gap-y-1 px-1">
|
|
49
51
|
{groups.map((group, i) => (
|
|
@@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|
|
2
2
|
import { MemoryRouter } from "react-router";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
import type { Channel } from "../catalog";
|
|
5
|
-
import { ChannelRowsContent } from "./ChannelRows";
|
|
5
|
+
import { ChannelRowsContent, countDirections, rowsOf } from "./ChannelRows";
|
|
6
6
|
|
|
7
7
|
const kafka: Channel = {
|
|
8
8
|
address: "orders/created",
|
|
@@ -18,6 +18,18 @@ const nats: Channel = {
|
|
|
18
18
|
messages: [],
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
/** A work queue the way extract-laravel and extract-celery write one: a job put on and worked. */
|
|
22
|
+
const queue: Channel = {
|
|
23
|
+
address: "default",
|
|
24
|
+
kind: "job",
|
|
25
|
+
title: "Queue · default",
|
|
26
|
+
messages: [
|
|
27
|
+
{ name: "App\\Jobs\\IndexOrder", title: "IndexOrder", doc: "Puts the order in the index.", direction: "send" },
|
|
28
|
+
{ name: "App\\Jobs\\IndexOrder", title: "IndexOrder", doc: "Worked by `IndexOrder::handle`.", direction: "receive" },
|
|
29
|
+
{ name: "App\\Jobs\\ImportBatch", title: "ImportBatch", doc: "Worked by `ImportBatch::handle`.", direction: "receive" },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
21
33
|
describe("ChannelRows Kafka UI integration", () => {
|
|
22
34
|
it("links only Kafka cards to their exact topic when configured", () => {
|
|
23
35
|
const markup = renderToStaticMarkup(
|
|
@@ -50,5 +62,68 @@ describe("ChannelRows Kafka UI integration", () => {
|
|
|
50
62
|
|
|
51
63
|
expect(markup).not.toContain("view in Kafka UI");
|
|
52
64
|
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("ChannelRows direction", () => {
|
|
68
|
+
it("draws each direction as its own chip", () => {
|
|
69
|
+
const markup = renderToStaticMarkup(
|
|
70
|
+
<MemoryRouter>
|
|
71
|
+
<ChannelRowsContent channels={[queue]} service="shop.app" kafkaUi="" />
|
|
72
|
+
</MemoryRouter>,
|
|
73
|
+
);
|
|
74
|
+
expect(markup.match(/data-direction="send"/g)).toHaveLength(1);
|
|
75
|
+
expect(markup.match(/data-direction="receive"/g)).toHaveLength(2);
|
|
76
|
+
expect(markup).toContain('class="chip dir-send"');
|
|
77
|
+
expect(markup).toContain('class="chip dir-receive"');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("keeps one direction when filtered, and says so when a channel has none of it", () => {
|
|
81
|
+
const rows = rowsOf(queue.messages, "send", false);
|
|
82
|
+
expect(rows.map((r) => `${r.directions[0]} ${r.message.title}`)).toEqual(["send IndexOrder"]);
|
|
83
|
+
|
|
84
|
+
const markup = renderToStaticMarkup(
|
|
85
|
+
<MemoryRouter>
|
|
86
|
+
<ChannelRowsContent channels={[queue]} service="shop.app" kafkaUi="" filter="send" />
|
|
87
|
+
</MemoryRouter>,
|
|
88
|
+
);
|
|
89
|
+
expect(markup).not.toContain("ImportBatch");
|
|
90
|
+
expect(markup).not.toContain("nothing sent");
|
|
53
91
|
|
|
92
|
+
const empty = renderToStaticMarkup(
|
|
93
|
+
<MemoryRouter>
|
|
94
|
+
<ChannelRowsContent
|
|
95
|
+
channels={[{ ...queue, messages: queue.messages.filter((m) => m.direction === "receive") }]}
|
|
96
|
+
service="shop.app"
|
|
97
|
+
kafkaUi=""
|
|
98
|
+
filter="send"
|
|
99
|
+
/>
|
|
100
|
+
</MemoryRouter>,
|
|
101
|
+
);
|
|
102
|
+
expect(empty).toContain("nothing sent");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("folds the send and receive of one message into a row wearing both chips", () => {
|
|
106
|
+
const rows = rowsOf(queue.messages, "all", true);
|
|
107
|
+
expect(rows.map((r) => `${r.message.title}: ${r.directions.join("+")}`)).toEqual([
|
|
108
|
+
"IndexOrder: send+receive",
|
|
109
|
+
"ImportBatch: receive",
|
|
110
|
+
]);
|
|
111
|
+
// The sent message carries the sender's doc, whichever came first.
|
|
112
|
+
const reversed = rowsOf([...queue.messages].reverse(), "all", true);
|
|
113
|
+
expect(reversed[1]?.message.doc).toBe("Puts the order in the index.");
|
|
114
|
+
expect(reversed[1]?.directions).toEqual(["send", "receive"]);
|
|
115
|
+
|
|
116
|
+
const markup = renderToStaticMarkup(
|
|
117
|
+
<MemoryRouter>
|
|
118
|
+
<ChannelRowsContent channels={[queue]} service="shop.app" kafkaUi="" grouped />
|
|
119
|
+
</MemoryRouter>,
|
|
120
|
+
);
|
|
121
|
+
// Two rows for three messages, three chips between them.
|
|
122
|
+
expect(markup.match(/<li /g)).toHaveLength(2);
|
|
123
|
+
expect(markup.match(/data-direction=/g)).toHaveLength(3);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("counts what travels each way across every channel", () => {
|
|
127
|
+
expect(countDirections([queue, kafka])).toEqual({ send: 1, receive: 2 });
|
|
128
|
+
});
|
|
54
129
|
});
|