@shortlink-org/portolan 0.1.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/LICENSE +21 -0
- package/README.md +387 -0
- package/catalog/enum_test.go +46 -0
- package/catalog/model.go +932 -0
- package/catalog/roundtrip_test.go +185 -0
- package/catalog/via_test.go +38 -0
- package/cli/portolan.mjs +368 -0
- package/go.mod +14 -0
- package/go.sum +14 -0
- package/index.html +13 -0
- package/internal/commands/cargo.go +154 -0
- package/internal/commands/commands.go +143 -0
- package/internal/commands/commands_test.go +323 -0
- package/internal/commands/gradle.go +77 -0
- package/internal/commands/justfile.go +82 -0
- package/internal/commands/makefile.go +106 -0
- package/internal/commands/maven.go +93 -0
- package/internal/commands/packagejson.go +159 -0
- package/internal/commands/pyproject.go +169 -0
- package/internal/commands/taskfile.go +101 -0
- package/internal/commands/testdata/estate/.cargo/config.toml +7 -0
- package/internal/commands/testdata/estate/Makefile +33 -0
- package/internal/commands/testdata/estate/Taskfile.yml +21 -0
- package/internal/commands/testdata/estate/build.gradle.kts +21 -0
- package/internal/commands/testdata/estate/justfile +20 -0
- package/internal/commands/testdata/estate/package.json +13 -0
- package/internal/commands/testdata/estate/pom.xml +29 -0
- package/internal/commands/testdata/estate/pyproject.toml +27 -0
- package/internal/commands/testdata/estate/xtask/src/main.rs +27 -0
- package/internal/commands/testdata/golden/commands.json +305 -0
- package/internal/gohttp/analyze.go +2500 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +143 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +36 -0
- package/internal/goscan/tree.go +155 -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 +62 -0
- package/internal/wsdl/parse.go +920 -0
- package/internal/wsdl/parse_test.go +133 -0
- package/package.json +117 -0
- package/plugin/describe.go +107 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +117 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +1160 -0
- package/plugins/extract-adr/describe.go +19 -0
- package/plugins/extract-adr/describe_test.go +11 -0
- package/plugins/extract-adr/extract.go +153 -0
- package/plugins/extract-adr/extract_test.go +350 -0
- package/plugins/extract-adr/history.go +99 -0
- package/plugins/extract-adr/main.go +65 -0
- package/plugins/extract-adr/options.schema.json +26 -0
- package/plugins/extract-adr/parse.go +642 -0
- package/plugins/extract-adr/parse_test.go +405 -0
- package/plugins/extract-asyncapi/describe.go +19 -0
- package/plugins/extract-asyncapi/describe_test.go +11 -0
- package/plugins/extract-asyncapi/extract.go +315 -0
- package/plugins/extract-asyncapi/extract_test.go +197 -0
- package/plugins/extract-asyncapi/main.go +48 -0
- package/plugins/extract-asyncapi/options.schema.json +26 -0
- package/plugins/extract-asyncapi/spec.go +151 -0
- package/plugins/extract-celery/README.md +169 -0
- package/plugins/extract-celery/extract.py +346 -0
- package/plugins/extract-celery/extract_test.py +192 -0
- package/plugins/extract-celery/main.py +66 -0
- package/plugins/extract-celery/options.py +31 -0
- package/plugins/extract-celery/options.schema.json +31 -0
- package/plugins/extract-celery/producers.py +156 -0
- package/plugins/extract-celery/routes_test.py +106 -0
- package/plugins/extract-commands/describe.go +19 -0
- package/plugins/extract-commands/describe_test.go +11 -0
- package/plugins/extract-commands/extract.go +79 -0
- package/plugins/extract-commands/extract_test.go +92 -0
- package/plugins/extract-commands/main.go +55 -0
- package/plugins/extract-commands/options.schema.json +20 -0
- package/plugins/extract-csr/avro.go +258 -0
- package/plugins/extract-csr/describe.go +19 -0
- package/plugins/extract-csr/describe_test.go +11 -0
- package/plugins/extract-csr/extract.go +338 -0
- package/plugins/extract-csr/extract_test.go +374 -0
- package/plugins/extract-csr/jsonschema.go +343 -0
- package/plugins/extract-csr/lock.go +25 -0
- package/plugins/extract-csr/main.go +84 -0
- package/plugins/extract-csr/options.schema.json +43 -0
- package/plugins/extract-csr/subject.go +102 -0
- package/plugins/extract-django/README.md +386 -0
- package/plugins/extract-django/apps.py +100 -0
- package/plugins/extract-django/auth.py +302 -0
- package/plugins/extract-django/auth_test.py +188 -0
- package/plugins/extract-django/clients.py +121 -0
- package/plugins/extract-django/contracts.py +458 -0
- package/plugins/extract-django/database.py +119 -0
- package/plugins/extract-django/domain.py +318 -0
- package/plugins/extract-django/events.py +86 -0
- package/plugins/extract-django/extract.py +601 -0
- package/plugins/extract-django/extract_test.py +397 -0
- package/plugins/extract-django/flows.py +1278 -0
- package/plugins/extract-django/flows_test.py +338 -0
- package/plugins/extract-django/ids.py +42 -0
- package/plugins/extract-django/ids_test.py +30 -0
- package/plugins/extract-django/lifecycle.py +222 -0
- package/plugins/extract-django/lifecycle_test.py +76 -0
- package/plugins/extract-django/main.py +66 -0
- package/plugins/extract-django/openapi.py +229 -0
- package/plugins/extract-django/openapi_test.py +60 -0
- package/plugins/extract-django/operations.py +96 -0
- package/plugins/extract-django/options.py +61 -0
- package/plugins/extract-django/options.schema.json +126 -0
- package/plugins/extract-django/routing.py +214 -0
- package/plugins/extract-django/routing_test.py +198 -0
- package/plugins/extract-django/serializers.py +558 -0
- package/plugins/extract-django/store.py +434 -0
- package/plugins/extract-django/transport.py +242 -0
- package/plugins/extract-flows/describe.go +19 -0
- package/plugins/extract-flows/describe_test.go +11 -0
- package/plugins/extract-flows/extract.go +91 -0
- package/plugins/extract-flows/main.go +45 -0
- package/plugins/extract-flows/options.schema.json +16 -0
- package/plugins/extract-flows/parse.go +593 -0
- package/plugins/extract-flows/parse_test.go +204 -0
- package/plugins/extract-glossary/describe.go +19 -0
- package/plugins/extract-glossary/describe_test.go +11 -0
- package/plugins/extract-glossary/extract.go +115 -0
- package/plugins/extract-glossary/extract_test.go +220 -0
- package/plugins/extract-glossary/main.go +59 -0
- package/plugins/extract-glossary/options.schema.json +21 -0
- package/plugins/extract-glossary/parse.go +214 -0
- package/plugins/extract-glossary/parse_test.go +203 -0
- package/plugins/extract-go/aggregate.go +214 -0
- package/plugins/extract-go/client.go +409 -0
- package/plugins/extract-go/client_test.go +266 -0
- package/plugins/extract-go/describe.go +19 -0
- package/plugins/extract-go/describe_test.go +11 -0
- package/plugins/extract-go/enum.go +195 -0
- package/plugins/extract-go/enum_test.go +82 -0
- package/plugins/extract-go/event.go +99 -0
- package/plugins/extract-go/extract.go +191 -0
- package/plugins/extract-go/extract_test.go +261 -0
- package/plugins/extract-go/flow.go +1441 -0
- package/plugins/extract-go/flow_test.go +609 -0
- package/plugins/extract-go/httpclient.go +174 -0
- package/plugins/extract-go/httpclient_test.go +296 -0
- package/plugins/extract-go/ids.go +92 -0
- package/plugins/extract-go/layout.go +225 -0
- package/plugins/extract-go/layout_test.go +125 -0
- package/plugins/extract-go/lifecycle.go +324 -0
- package/plugins/extract-go/lifecycle_test.go +108 -0
- package/plugins/extract-go/main.go +79 -0
- package/plugins/extract-go/operation.go +157 -0
- package/plugins/extract-go/options.schema.json +66 -0
- package/plugins/extract-go/source.go +316 -0
- package/plugins/extract-go/transport.go +321 -0
- package/plugins/extract-go/transport_test.go +145 -0
- package/plugins/extract-go/wiring.go +434 -0
- package/plugins/extract-go-nats/describe.go +19 -0
- package/plugins/extract-go-nats/describe_test.go +11 -0
- package/plugins/extract-go-nats/extract.go +177 -0
- package/plugins/extract-go-nats/extract_test.go +389 -0
- package/plugins/extract-go-nats/index.go +379 -0
- package/plugins/extract-go-nats/main.go +42 -0
- package/plugins/extract-go-nats/options.schema.json +21 -0
- package/plugins/extract-go-nats/resolve.go +161 -0
- package/plugins/extract-go-nats/sites.go +224 -0
- package/plugins/extract-graphql/describe.go +19 -0
- package/plugins/extract-graphql/describe_test.go +11 -0
- package/plugins/extract-graphql/extract.go +433 -0
- package/plugins/extract-graphql/extract_test.go +256 -0
- package/plugins/extract-graphql/ids.go +49 -0
- package/plugins/extract-graphql/lex.go +237 -0
- package/plugins/extract-graphql/main.go +51 -0
- package/plugins/extract-graphql/options.schema.json +31 -0
- package/plugins/extract-graphql/parse.go +621 -0
- package/plugins/extract-graphql/parse_test.go +122 -0
- package/plugins/extract-http-clients/describe.go +19 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +705 -0
- package/plugins/extract-http-clients/extract_test.go +1263 -0
- package/plugins/extract-http-clients/main.go +42 -0
- package/plugins/extract-http-clients/options.schema.json +33 -0
- package/plugins/extract-java/README.md +190 -0
- package/plugins/extract-java/build/org/portolan/extract/Catalog.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Clients$1.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Clients$2.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Clients$Call.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Clients$Client.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Clients$Route.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Clients.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Domain$Aggregate.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Domain.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Events$Found.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Events.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Flows$1.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Flows$Binding.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Flows$Draft.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Flows$Frame.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Flows$Options.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Flows.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Ids.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Json$Reader.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Json.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Lifecycle$1.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Lifecycle$Move.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Lifecycle.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/OpenApi$Operation.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/OpenApi$Spec.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/OpenApi.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Operations$1.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Operations$UseCase.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Operations.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Proto$Rpc.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Proto$Service.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Proto.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-java/build/org/portolan/extract/Protocol.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Source$Project.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Source$Unit.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Source.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Transport$Endpoint.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Transport.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Yaml$Line.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Yaml$Reader.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Yaml.class +0 -0
- package/plugins/extract-java/options.schema.json +70 -0
- package/plugins/extract-openapi/describe.go +19 -0
- package/plugins/extract-openapi/describe_test.go +11 -0
- package/plugins/extract-openapi/discover.go +243 -0
- package/plugins/extract-openapi/extract.go +526 -0
- package/plugins/extract-openapi/extract_test.go +545 -0
- package/plugins/extract-openapi/main.go +75 -0
- package/plugins/extract-openapi/options.schema.json +62 -0
- package/plugins/extract-openapi/spec.go +350 -0
- package/plugins/extract-project/README.md +22 -0
- package/plugins/extract-project/describe.go +19 -0
- package/plugins/extract-project/describe_test.go +11 -0
- package/plugins/extract-project/extract.go +221 -0
- package/plugins/extract-project/extract_test.go +109 -0
- package/plugins/extract-project/main.go +41 -0
- package/plugins/extract-project/options.schema.json +48 -0
- package/plugins/extract-proto/ast.go +125 -0
- package/plugins/extract-proto/consumes.go +77 -0
- package/plugins/extract-proto/describe.go +19 -0
- package/plugins/extract-proto/extract.go +293 -0
- package/plugins/extract-proto/extract_test.go +459 -0
- package/plugins/extract-proto/ids.go +89 -0
- package/plugins/extract-proto/ids_test.go +57 -0
- package/plugins/extract-proto/lex.go +285 -0
- package/plugins/extract-proto/main.go +100 -0
- package/plugins/extract-proto/module.go +120 -0
- package/plugins/extract-proto/options.schema.json +51 -0
- package/plugins/extract-proto/parse.go +720 -0
- package/plugins/extract-proto/parse_test.go +307 -0
- package/plugins/extract-proto/provides.go +236 -0
- package/plugins/extract-proto/resolve.go +222 -0
- package/plugins/extract-proto/resolve_test.go +119 -0
- package/plugins/extract-python-kafka/README.md +36 -0
- package/plugins/extract-python-kafka/extract.py +211 -0
- package/plugins/extract-python-kafka/extract_test.py +68 -0
- package/plugins/extract-python-kafka/main.py +59 -0
- package/plugins/extract-python-kafka/options.py +29 -0
- package/plugins/extract-python-kafka/options.schema.json +31 -0
- package/plugins/extract-redis/README.md +11 -0
- package/plugins/extract-redis/describe.go +19 -0
- package/plugins/extract-redis/describe_test.go +11 -0
- package/plugins/extract-redis/extract.go +183 -0
- package/plugins/extract-redis/extract_test.go +168 -0
- package/plugins/extract-redis/keyspaces.go +469 -0
- package/plugins/extract-redis/main.go +44 -0
- package/plugins/extract-redis/options.schema.json +30 -0
- package/plugins/extract-river/describe.go +19 -0
- package/plugins/extract-river/describe_test.go +11 -0
- package/plugins/extract-river/extract.go +507 -0
- package/plugins/extract-river/extract_test.go +132 -0
- package/plugins/extract-river/main.go +42 -0
- package/plugins/extract-river/options.schema.json +21 -0
- package/plugins/extract-rust/Cargo.lock +133 -0
- package/plugins/extract-rust/Cargo.toml +23 -0
- package/plugins/extract-rust/README.md +204 -0
- package/plugins/extract-rust/options.schema.json +65 -0
- package/plugins/extract-rust/rustfmt.toml +1 -0
- package/plugins/extract-sql/ddl.go +893 -0
- package/plugins/extract-sql/ddl_test.go +401 -0
- package/plugins/extract-sql/describe.go +19 -0
- package/plugins/extract-sql/describe_test.go +11 -0
- package/plugins/extract-sql/layout.go +221 -0
- package/plugins/extract-sql/layout_test.go +55 -0
- package/plugins/extract-sql/lineage.go +107 -0
- package/plugins/extract-sql/main.go +141 -0
- package/plugins/extract-sql/maps.go +564 -0
- package/plugins/extract-sql/maps_java.go +117 -0
- package/plugins/extract-sql/maps_rust.go +333 -0
- package/plugins/extract-sql/maps_rust_test.go +70 -0
- package/plugins/extract-sql/maps_test.go +204 -0
- package/plugins/extract-sql/maps_ts.go +398 -0
- package/plugins/extract-sql/maps_ts_test.go +136 -0
- package/plugins/extract-sql/options.schema.json +53 -0
- package/plugins/extract-sql/projection.go +70 -0
- package/plugins/extract-sql/projection_test.go +50 -0
- package/plugins/extract-sql/store.go +420 -0
- package/plugins/extract-sql/store_test.go +233 -0
- package/plugins/extract-sql/view.go +295 -0
- package/plugins/extract-ts/README.md +306 -0
- package/plugins/extract-ts/ast.test.ts +178 -0
- package/plugins/extract-ts/ast.ts +648 -0
- package/plugins/extract-ts/clients.ts +185 -0
- package/plugins/extract-ts/domain.ts +164 -0
- package/plugins/extract-ts/extract.test.ts +208 -0
- package/plugins/extract-ts/extract.ts +200 -0
- package/plugins/extract-ts/flows.ts +783 -0
- package/plugins/extract-ts/graphql.test.ts +73 -0
- package/plugins/extract-ts/graphql.ts +181 -0
- package/plugins/extract-ts/ids.test.ts +21 -0
- package/plugins/extract-ts/ids.ts +52 -0
- package/plugins/extract-ts/lifecycle.ts +138 -0
- package/plugins/extract-ts/main.ts +48 -0
- package/plugins/extract-ts/openapi.test.ts +64 -0
- package/plugins/extract-ts/openapi.ts +99 -0
- package/plugins/extract-ts/operations.ts +93 -0
- package/plugins/extract-ts/options.schema.json +96 -0
- package/plugins/extract-ts/source.test.ts +174 -0
- package/plugins/extract-ts/source.ts +465 -0
- package/plugins/extract-ts/transport.ts +147 -0
- package/plugins/extract-ts/tsconfig.json +13 -0
- package/plugins/extract-ts/wiring.ts +190 -0
- package/plugins/extract-watermill/describe.go +19 -0
- package/plugins/extract-watermill/describe_test.go +11 -0
- package/plugins/extract-watermill/extract.go +1228 -0
- package/plugins/extract-watermill/extract_test.go +234 -0
- package/plugins/extract-watermill/main.go +41 -0
- package/plugins/extract-watermill/options.schema.json +21 -0
- package/plugins/extract-wsdl/describe.go +19 -0
- package/plugins/extract-wsdl/describe_test.go +11 -0
- package/plugins/extract-wsdl/extract.go +148 -0
- package/plugins/extract-wsdl/extract_test.go +45 -0
- package/plugins/extract-wsdl/main.go +53 -0
- package/plugins/extract-wsdl/options.schema.json +59 -0
- package/plugins/fetch-bsr/auth.go +114 -0
- package/plugins/fetch-bsr/bsr.go +240 -0
- package/plugins/fetch-bsr/cache.go +66 -0
- package/plugins/fetch-bsr/describe.go +19 -0
- package/plugins/fetch-bsr/fetch.go +192 -0
- package/plugins/fetch-bsr/fetch_test.go +465 -0
- package/plugins/fetch-bsr/lock.go +71 -0
- package/plugins/fetch-bsr/main.go +117 -0
- package/plugins/fetch-bsr/options.schema.json +42 -0
- package/plugins/fetch-csr/auth.go +95 -0
- package/plugins/fetch-csr/cache.go +69 -0
- package/plugins/fetch-csr/describe.go +19 -0
- package/plugins/fetch-csr/describe_test.go +11 -0
- package/plugins/fetch-csr/fetch.go +254 -0
- package/plugins/fetch-csr/fetch_test.go +494 -0
- package/plugins/fetch-csr/lock.go +95 -0
- package/plugins/fetch-csr/main.go +122 -0
- package/plugins/fetch-csr/options.schema.json +42 -0
- package/plugins/fetch-csr/registry.go +209 -0
- package/plugins/fetch-git/cache.go +65 -0
- package/plugins/fetch-git/describe.go +19 -0
- package/plugins/fetch-git/describe_test.go +11 -0
- package/plugins/fetch-git/fetch.go +181 -0
- package/plugins/fetch-git/fetch_test.go +332 -0
- package/plugins/fetch-git/git.go +169 -0
- package/plugins/fetch-git/lock.go +72 -0
- package/plugins/fetch-git/main.go +127 -0
- package/plugins/fetch-git/offline.go +39 -0
- package/plugins/fetch-git/options.schema.json +42 -0
- package/plugins/fetch-git/pin.go +90 -0
- package/plugins/fetch-git/pin_test.go +74 -0
- package/plugins/gen-backstage/describe.go +17 -0
- package/plugins/gen-backstage/main.go +22 -0
- package/plugins/gen-backstage/options.schema.json +30 -0
- package/plugins/gen-backstage/plugin.go +473 -0
- package/plugins/gen-backstage/plugin_test.go +145 -0
- package/plugins/gen-backstage.wasm +0 -0
- package/plugins/gen-markdown/adr.go +162 -0
- package/plugins/gen-markdown/aggregate.go +362 -0
- package/plugins/gen-markdown/canonical.go +93 -0
- package/plugins/gen-markdown/context.go +90 -0
- package/plugins/gen-markdown/coverage_test.go +89 -0
- package/plugins/gen-markdown/describe.go +19 -0
- package/plugins/gen-markdown/describe_test.go +11 -0
- package/plugins/gen-markdown/external.go +71 -0
- package/plugins/gen-markdown/flow.go +277 -0
- package/plugins/gen-markdown/glossary.go +76 -0
- package/plugins/gen-markdown/glossary_test.go +148 -0
- package/plugins/gen-markdown/llms.go +302 -0
- package/plugins/gen-markdown/main.go +22 -0
- package/plugins/gen-markdown/markdown.go +254 -0
- package/plugins/gen-markdown/markdown_test.go +100 -0
- package/plugins/gen-markdown/module.go +107 -0
- package/plugins/gen-markdown/options.schema.json +16 -0
- package/plugins/gen-markdown/plugin.go +39 -0
- package/plugins/gen-markdown/quality_test.go +176 -0
- package/plugins/gen-markdown/redis_test.go +38 -0
- package/plugins/gen-markdown/render.go +344 -0
- package/plugins/gen-markdown/render_test.go +186 -0
- package/plugins/gen-markdown/service.go +456 -0
- package/plugins/gen-markdown/source.go +177 -0
- package/plugins/gen-markdown/store.go +201 -0
- package/plugins/gen-markdown.wasm +0 -0
- package/plugins/gen-mermaid/describe.go +17 -0
- package/plugins/gen-mermaid/main.go +22 -0
- package/plugins/gen-mermaid/options.schema.json +10 -0
- package/plugins/gen-mermaid/plugin.go +104 -0
- package/plugins/gen-mermaid/plugin_test.go +33 -0
- package/plugins/gen-mermaid.wasm +0 -0
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/pyplugin/catalog.py +209 -0
- package/plugins/pyplugin/celery_conf.py +436 -0
- package/plugins/pyplugin/celery_tasks.py +76 -0
- package/plugins/pyplugin/kafka.py +570 -0
- package/plugins/pyplugin/names.py +38 -0
- package/plugins/pyplugin/protocol.py +79 -0
- package/plugins/pyplugin/source.py +285 -0
- package/plugins/verify-codeowners/describe.go +19 -0
- package/plugins/verify-codeowners/describe_test.go +11 -0
- package/plugins/verify-codeowners/main.go +75 -0
- package/plugins/verify-codeowners/match.go +85 -0
- package/plugins/verify-codeowners/match_test.go +47 -0
- package/plugins/verify-codeowners/options.schema.json +15 -0
- package/plugins/verify-codeowners/owners.go +164 -0
- package/plugins/verify-codeowners/owners_test.go +225 -0
- package/plugins/verify-codeowners/parse.go +90 -0
- package/plugins/verify-codeowners/parse_test.go +62 -0
- package/plugins/verify-otel/describe.go +19 -0
- package/plugins/verify-otel/describe_test.go +11 -0
- package/plugins/verify-otel/main.go +53 -0
- package/plugins/verify-otel/match.go +336 -0
- package/plugins/verify-otel/options.schema.json +30 -0
- package/plugins/verify-otel/otlp.go +200 -0
- package/plugins/verify-otel/verify.go +734 -0
- package/plugins/verify-otel/verify_test.go +460 -0
- package/portolan.json +1073 -0
- package/public/favicon.svg +12 -0
- package/schema/portolan.schema.json +2558 -0
- package/scripts/README.md +53 -0
- package/scripts/build-report.mjs +60 -0
- package/scripts/build-report.test.mjs +44 -0
- package/scripts/builtin-plugins.mjs +49 -0
- package/scripts/catalog-sources.mjs +67 -0
- package/scripts/diff.mjs +315 -0
- package/scripts/diff.test.mjs +151 -0
- package/scripts/forge-comment.mjs +144 -0
- package/scripts/forge-comment.test.mjs +151 -0
- package/scripts/forge-release.mjs +138 -0
- package/scripts/forge-release.test.mjs +106 -0
- package/scripts/forge.mjs +61 -0
- package/scripts/gen-likec4.mjs +854 -0
- package/scripts/gen-likec4.test.mjs +258 -0
- package/scripts/gen.mjs +603 -0
- package/scripts/generated-docs.test.mjs +66 -0
- package/scripts/local-api.mjs +1192 -0
- package/scripts/local-api.test.mjs +387 -0
- package/scripts/manifest.mjs +214 -0
- package/scripts/manifest.test.mjs +148 -0
- package/scripts/output-path.mjs +79 -0
- package/scripts/output-path.test.mjs +45 -0
- package/scripts/package-smoke.mjs +37 -0
- package/scripts/plugin-host.mjs +419 -0
- package/scripts/plugin-host.test.mjs +109 -0
- package/scripts/plugin-wasm-worker.mjs +34 -0
- package/scripts/run-builtin.mjs +81 -0
- package/scripts/schema.mjs +357 -0
- package/scripts/site-docs.mjs +295 -0
- package/scripts/site-docs.test.mjs +181 -0
- package/scripts/vendor-lock.mjs +58 -0
- package/scripts/vendor-lock.test.mjs +69 -0
- package/src/README.md +38 -0
- package/src/app/App.tsx +448 -0
- package/src/app/BranchPicker.test.ts +46 -0
- package/src/app/BranchPicker.tsx +153 -0
- package/src/app/Breadcrumbs.test.ts +38 -0
- package/src/app/Breadcrumbs.tsx +223 -0
- package/src/app/BuildStamp.tsx +146 -0
- package/src/app/CommandPalette.tsx +292 -0
- package/src/app/HashScroll.tsx +45 -0
- package/src/app/Sidebar.tsx +1742 -0
- package/src/app/TopBar.tsx +393 -0
- package/src/app/density.tsx +64 -0
- package/src/app/forge-access.test.ts +27 -0
- package/src/app/forge-access.tsx +80 -0
- package/src/app/panels.tsx +141 -0
- package/src/app/pins.tsx +242 -0
- package/src/app/query-client.ts +24 -0
- package/src/app/responsive.ts +55 -0
- package/src/app/shortcuts.test.ts +39 -0
- package/src/app/shortcuts.tsx +282 -0
- package/src/app/theme.tsx +51 -0
- package/src/app/title.test.ts +14 -0
- package/src/app/title.ts +30 -0
- package/src/app/toast.tsx +59 -0
- package/src/app/tree-keys.test.ts +80 -0
- package/src/app/tree-keys.ts +87 -0
- package/src/app/ui-store.ts +52 -0
- package/src/catalog-error.test.ts +105 -0
- package/src/catalog-profile.test.ts +59 -0
- package/src/catalog-profile.ts +101 -0
- package/src/catalog-stores.test.ts +431 -0
- package/src/catalog.test.ts +1215 -0
- package/src/catalog.ts +3125 -0
- package/src/chat/Cards.tsx +408 -0
- package/src/chat/ChatPanel.tsx +79 -0
- package/src/chat/ChatSettings.tsx +219 -0
- package/src/chat/Composer.tsx +99 -0
- package/src/chat/Conversation.tsx +281 -0
- package/src/chat/Header.tsx +46 -0
- package/src/chat/Message.tsx +49 -0
- package/src/chat/Notice.tsx +195 -0
- package/src/chat/Starter.tsx +67 -0
- package/src/chat/Steps.tsx +45 -0
- package/src/chat/Waiting.tsx +87 -0
- package/src/chat/flags.test.ts +59 -0
- package/src/chat/flags.ts +155 -0
- package/src/chat/linkify.test.ts +30 -0
- package/src/chat/linkify.ts +45 -0
- package/src/chat/prefs.ts +56 -0
- package/src/chat/prompt.test.ts +43 -0
- package/src/chat/prompt.ts +157 -0
- package/src/chat/store.ts +35 -0
- package/src/chat/tools.ts +58 -0
- package/src/chat/transport.ts +70 -0
- package/src/components/AdrRow.tsx +29 -0
- package/src/components/AnchorLink.tsx +67 -0
- package/src/components/ApiReference.tsx +134 -0
- package/src/components/AsyncApiReference.tsx +120 -0
- package/src/components/CatalogStamp.tsx +155 -0
- package/src/components/ChannelRows.tsx +132 -0
- package/src/components/CommandRows.tsx +139 -0
- package/src/components/DiagramSkeleton.tsx +71 -0
- package/src/components/EditorLink.tsx +85 -0
- package/src/components/FieldTree.tsx +305 -0
- package/src/components/FlowTrigger.tsx +34 -0
- package/src/components/Ident.tsx +89 -0
- package/src/components/Integrations.tsx +243 -0
- package/src/components/LifecycleDiagram.tsx +110 -0
- package/src/components/MachineDocs.tsx +42 -0
- package/src/components/Markdown.tsx +173 -0
- package/src/components/MarkdownTable.tsx +206 -0
- package/src/components/Mermaid.tsx +211 -0
- package/src/components/MethodRows.tsx +309 -0
- package/src/components/Overlay.tsx +125 -0
- package/src/components/PageHeader.tsx +189 -0
- package/src/components/RowActions.tsx +97 -0
- package/src/components/SchemaDocument.tsx +89 -0
- package/src/components/Select.tsx +126 -0
- package/src/components/ShapeRows.tsx +118 -0
- package/src/components/SourceDoc.tsx +82 -0
- package/src/components/SourcePreview.tsx +441 -0
- package/src/components/TabRow.tsx +182 -0
- package/src/components/TechIcon.tsx +34 -0
- package/src/components/Toc.tsx +132 -0
- package/src/components/WhatLinksHere.tsx +273 -0
- package/src/components/WsdlReference.tsx +84 -0
- package/src/components/ddd-icons.tsx +107 -0
- package/src/components/kind.tsx +157 -0
- package/src/components/logo.tsx +61 -0
- package/src/components/primitives.tsx +264 -0
- package/src/data.test.ts +18 -0
- package/src/data.ts +202 -0
- package/src/enrich.test.ts +1150 -0
- package/src/enrich.ts +672 -0
- package/src/er/ErCanvas.tsx +533 -0
- package/src/er/RedisSchema.tsx +74 -0
- package/src/er/RelationCard.tsx +267 -0
- package/src/er/StoreHeader.tsx +155 -0
- package/src/er/TableNode.tsx +84 -0
- package/src/er/ViewNode.tsx +83 -0
- package/src/er/layout.test.ts +115 -0
- package/src/er/layout.ts +77 -0
- package/src/er/lineage.test.ts +140 -0
- package/src/er/lineage.ts +107 -0
- package/src/er/markers.tsx +79 -0
- package/src/er/spec.test.ts +344 -0
- package/src/er/spec.ts +426 -0
- package/src/flow/ChainList.tsx +162 -0
- package/src/flow/FlowTable.tsx +123 -0
- package/src/flow/FlowToolbar.tsx +372 -0
- package/src/flow/StepDetail.tsx +380 -0
- package/src/flow/StepRail.tsx +428 -0
- package/src/flow/answers.test.ts +50 -0
- package/src/flow/answers.ts +58 -0
- package/src/flow/chain.test.ts +383 -0
- package/src/flow/chain.ts +353 -0
- package/src/flow/chapters.test.ts +185 -0
- package/src/flow/chapters.ts +225 -0
- package/src/flow/continues.test.ts +102 -0
- package/src/flow/continues.ts +65 -0
- package/src/flow/cross-context.test.ts +97 -0
- package/src/flow/cross-context.ts +35 -0
- package/src/flow/mermaid.test.ts +22 -0
- package/src/flow/mermaid.ts +141 -0
- package/src/flow/outline.test.ts +212 -0
- package/src/flow/outline.ts +170 -0
- package/src/flow/paths.test.ts +129 -0
- package/src/flow/paths.ts +147 -0
- package/src/flow/prefs.test.ts +80 -0
- package/src/flow/prefs.ts +104 -0
- package/src/graph/DependencyGraph.tsx +283 -0
- package/src/graph/DependencyNodes.tsx +162 -0
- package/src/graph/EdgeLabel.tsx +61 -0
- package/src/graph/FocusedEventGraph.tsx +211 -0
- package/src/graph/GraphToolbar.tsx +187 -0
- package/src/graph/RoutedEdge.tsx +80 -0
- package/src/graph/dependency-layout.test.ts +360 -0
- package/src/graph/dependency-layout.ts +444 -0
- package/src/graph/elk.test.ts +70 -0
- package/src/graph/elk.ts +287 -0
- package/src/graph/nodes.tsx +97 -0
- package/src/graph/theme.ts +81 -0
- package/src/graph/useElkFlow.ts +125 -0
- package/src/index.css +1547 -0
- package/src/language/InLanguage.tsx +40 -0
- package/src/language/TermCard.tsx +132 -0
- package/src/language/cards.test.ts +114 -0
- package/src/language/cards.ts +111 -0
- package/src/lib/admonition.test.ts +49 -0
- package/src/lib/admonition.ts +48 -0
- package/src/lib/adr.test.ts +131 -0
- package/src/lib/adr.ts +68 -0
- package/src/lib/api.test.ts +122 -0
- package/src/lib/api.ts +135 -0
- package/src/lib/backlinks.test.ts +330 -0
- package/src/lib/backlinks.ts +567 -0
- package/src/lib/branch-compare.ts +16 -0
- package/src/lib/build-info.test.ts +76 -0
- package/src/lib/build-info.ts +80 -0
- package/src/lib/catalog-diff.test.ts +275 -0
- package/src/lib/catalog-diff.ts +655 -0
- package/src/lib/clipboard.ts +43 -0
- package/src/lib/comparison-memory.test.ts +40 -0
- package/src/lib/comparison-memory.ts +39 -0
- package/src/lib/context-color.ts +27 -0
- package/src/lib/context-map.test.ts +148 -0
- package/src/lib/context-map.ts +399 -0
- package/src/lib/data-model.test.ts +245 -0
- package/src/lib/data-model.ts +399 -0
- package/src/lib/data-problems.test.ts +178 -0
- package/src/lib/data-problems.ts +314 -0
- package/src/lib/derive.test.ts +251 -0
- package/src/lib/derive.ts +515 -0
- package/src/lib/editor-link.test.ts +125 -0
- package/src/lib/editor-link.ts +96 -0
- package/src/lib/editor-prefs.ts +27 -0
- package/src/lib/event-graph.test.ts +237 -0
- package/src/lib/event-graph.ts +327 -0
- package/src/lib/export-canvas.ts +37 -0
- package/src/lib/export-file.test.ts +30 -0
- package/src/lib/export-file.ts +20 -0
- package/src/lib/flow-tree.test.ts +177 -0
- package/src/lib/flow-tree.ts +160 -0
- package/src/lib/forge-refs.test.ts +50 -0
- package/src/lib/forge-refs.ts +60 -0
- package/src/lib/format.test.ts +35 -0
- package/src/lib/format.ts +64 -0
- package/src/lib/github-catalog.test.ts +301 -0
- package/src/lib/github-catalog.ts +610 -0
- package/src/lib/integrations.test.ts +119 -0
- package/src/lib/integrations.ts +133 -0
- package/src/lib/kinds.test.ts +91 -0
- package/src/lib/kinds.ts +204 -0
- package/src/lib/lifecycle.test.ts +93 -0
- package/src/lib/lifecycle.ts +165 -0
- package/src/lib/local-api.ts +194 -0
- package/src/lib/motion-features.ts +11 -0
- package/src/lib/motion.test.ts +120 -0
- package/src/lib/motion.tsx +269 -0
- package/src/lib/palette-recent.test.ts +80 -0
- package/src/lib/palette-recent.ts +90 -0
- package/src/lib/palette.test.ts +365 -0
- package/src/lib/palette.ts +593 -0
- package/src/lib/pins.test.ts +139 -0
- package/src/lib/pins.ts +130 -0
- package/src/lib/problems.test.ts +112 -0
- package/src/lib/project-preview.ts +5 -0
- package/src/lib/proto-problems.test.ts +121 -0
- package/src/lib/proto-problems.ts +65 -0
- package/src/lib/queries.test.ts +113 -0
- package/src/lib/queries.ts +131 -0
- package/src/lib/registry.test.ts +219 -0
- package/src/lib/registry.ts +208 -0
- package/src/lib/scenarios.ts +547 -0
- package/src/lib/setup-info.test.ts +199 -0
- package/src/lib/setup-info.ts +415 -0
- package/src/lib/shape.test.ts +289 -0
- package/src/lib/shape.ts +454 -0
- package/src/lib/sidebar-prefs.test.ts +32 -0
- package/src/lib/sidebar-prefs.ts +75 -0
- package/src/lib/source-code.test.ts +159 -0
- package/src/lib/source-code.ts +286 -0
- package/src/lib/source-doc.test.ts +131 -0
- package/src/lib/source-doc.ts +103 -0
- package/src/lib/source-highlight.test.ts +29 -0
- package/src/lib/source-highlight.ts +24 -0
- package/src/lib/source-link.test.ts +153 -0
- package/src/lib/source-link.ts +201 -0
- package/src/lib/spec-files.test.ts +24 -0
- package/src/lib/spec-files.ts +23 -0
- package/src/lib/table-directive.test.ts +139 -0
- package/src/lib/table-directive.ts +106 -0
- package/src/lib/tech.test.ts +120 -0
- package/src/lib/tech.ts +421 -0
- package/src/lib/terms.test.ts +67 -0
- package/src/lib/terms.ts +179 -0
- package/src/lib/wire-problems.test.ts +263 -0
- package/src/lib/wire-problems.ts +281 -0
- package/src/likec4/C4View.tsx +55 -0
- package/src/likec4/CanvasBridge.tsx +187 -0
- package/src/likec4/FlowView.tsx +235 -0
- package/src/likec4/InteractiveView.tsx +144 -0
- package/src/likec4/canvas-viewport.test.ts +149 -0
- package/src/likec4/canvas-viewport.ts +135 -0
- package/src/likec4/flow-edges.test.ts +51 -0
- package/src/likec4/flow-edges.ts +89 -0
- package/src/likec4/frame-theme.test.ts +76 -0
- package/src/likec4/frame-theme.ts +101 -0
- package/src/likec4/highlight-css.test.ts +49 -0
- package/src/likec4/highlight-css.ts +52 -0
- package/src/likec4/ids.test.ts +151 -0
- package/src/likec4/ids.ts +115 -0
- package/src/likec4/levels.tsx +70 -0
- package/src/likec4/mapping.test.ts +66 -0
- package/src/likec4/mapping.ts +54 -0
- package/src/likec4/reserved.json +141 -0
- package/src/likec4/view-index.ts +78 -0
- package/src/main.tsx +13 -0
- package/src/map/ContextMapGraph.tsx +274 -0
- package/src/map/ContextNode.tsx +70 -0
- package/src/map/FloatingEdge.tsx +86 -0
- package/src/merge.test.ts +1038 -0
- package/src/merge.ts +1004 -0
- package/src/pages/AdrDetail.tsx +242 -0
- package/src/pages/AdrIndex.tsx +114 -0
- package/src/pages/AggregatePage.tsx +727 -0
- package/src/pages/BlockPage.tsx +478 -0
- package/src/pages/CatalogFailure.tsx +111 -0
- package/src/pages/Changes.tsx +313 -0
- package/src/pages/ContextMap.tsx +534 -0
- package/src/pages/ContextPage.tsx +346 -0
- package/src/pages/EnumPage.tsx +282 -0
- package/src/pages/EventPage.tsx +686 -0
- package/src/pages/ExternalPage.tsx +204 -0
- package/src/pages/FlowDetail.tsx +663 -0
- package/src/pages/FlowIndex.tsx +303 -0
- package/src/pages/GraphPage.tsx +217 -0
- package/src/pages/Language.tsx +257 -0
- package/src/pages/ModulePage.tsx +366 -0
- package/src/pages/NotFound.tsx +54 -0
- package/src/pages/Overview.tsx +344 -0
- package/src/pages/Problems.tsx +360 -0
- package/src/pages/RegistryIndex.tsx +118 -0
- package/src/pages/ServicePage.tsx +746 -0
- package/src/pages/Settings.tsx +975 -0
- package/src/pages/StorePage.tsx +114 -0
- package/src/peek/PeekCard.test.ts +35 -0
- package/src/peek/PeekCard.tsx +118 -0
- package/src/peek/PeekLayer.tsx +147 -0
- package/src/peek/model.test.ts +156 -0
- package/src/peek/model.ts +253 -0
- package/src/routes.test.ts +222 -0
- package/src/routes.ts +449 -0
- package/src/selection/DetailPanel.tsx +1407 -0
- package/src/selection/hash.test.ts +73 -0
- package/src/selection/hash.ts +58 -0
- package/src/selection/model.test.ts +199 -0
- package/src/selection/model.ts +473 -0
- package/src/selection/pages.test.ts +115 -0
- package/src/selection/pages.ts +268 -0
- package/src/selection/store.test.ts +62 -0
- package/src/selection/store.ts +46 -0
- package/src/selection/sync.tsx +99 -0
- package/src/table/ColumnsMenu.tsx +72 -0
- package/src/table/DataTable.tsx +780 -0
- package/src/table/Facets.tsx +54 -0
- package/src/table/GroupMenu.tsx +69 -0
- package/src/table/Toolbar.tsx +190 -0
- package/src/table/cells.tsx +117 -0
- package/src/table/compare.test.ts +117 -0
- package/src/table/compare.ts +196 -0
- package/src/table/export.test.ts +88 -0
- package/src/table/export.ts +66 -0
- package/src/table/facet-groups.test.ts +74 -0
- package/src/table/facet-groups.ts +56 -0
- package/src/table/grouping.test.ts +84 -0
- package/src/table/grouping.ts +82 -0
- package/src/table/infer.test.ts +57 -0
- package/src/table/infer.ts +50 -0
- package/src/table/persist.test.ts +76 -0
- package/src/table/persist.ts +112 -0
- package/src/table/sort-url.test.ts +75 -0
- package/src/table/sort-url.ts +52 -0
- package/src/table/types.ts +80 -0
- package/src/table/useTableState.ts +220 -0
- package/src/test-catalog.ts +7 -0
- package/src/testing/estate/data__catalog.json +2847 -0
- package/src/testing/estate/data__flows.json +1724 -0
- package/src/testing/estate/examples__auth__portolan__api.json +212 -0
- package/src/testing/estate/examples__auth__portolan__domain.json +1204 -0
- package/src/testing/estate/examples__auth__portolan__glossary.json +226 -0
- package/src/testing/estate/examples__auth__portolan__observed.json +757 -0
- package/src/testing/estate/examples__auth__portolan__stores.json +195 -0
- package/src/testing/estate/examples__shop__cart__portolan__api.json +234 -0
- package/src/testing/estate/examples__shop__cart__portolan__commands.json +86 -0
- package/src/testing/estate/examples__shop__cart__portolan__domain.json +966 -0
- package/src/testing/estate/examples__shop__cart__portolan__observed.json +498 -0
- package/src/testing/estate/examples__shop__cart__portolan__stores.json +208 -0
- package/src/testing/estate/examples__shop__oms__portolan__glossary.json +98 -0
- package/src/testing/estate/examples__shop__oms__portolan__proto.json +186 -0
- package/src/testing/estate.ts +49 -0
- package/src/trail/Trail.tsx +81 -0
- package/src/trail/model.test.ts +157 -0
- package/src/trail/model.ts +189 -0
- package/src/trail/record.tsx +32 -0
- package/src/trail/store.test.ts +66 -0
- package/src/trail/store.ts +102 -0
- package/tsconfig.json +35 -0
- package/vite.config.ts +171 -0
package/src/merge.ts
ADDED
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
// Merging sources into one catalog.
|
|
2
|
+
//
|
|
3
|
+
// The estate is not written down in one place. A service publishes facts about
|
|
4
|
+
// itself next to its own code, an authored file carries the prose nobody can
|
|
5
|
+
// generate, and what a reader sees is the union. There is no master file, and
|
|
6
|
+
// deliberately so: a single catalog.json would have to be written by whoever
|
|
7
|
+
// owns the repository it lives in, which is exactly the bottleneck this avoids.
|
|
8
|
+
//
|
|
9
|
+
// Two things follow from that, and both are the point rather than the cost:
|
|
10
|
+
//
|
|
11
|
+
// - Merging happens BEFORE validation. Referential integrity is a property of
|
|
12
|
+
// the union, not of any one source, so a source that names a peer it does
|
|
13
|
+
// not own is normal rather than broken.
|
|
14
|
+
// - A conflict is a value, not an exception. Two sources disagreeing about a
|
|
15
|
+
// context's name is a fact about the estate that belongs on the Problems
|
|
16
|
+
// page, next to the other things that are true and unfortunate.
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
Aggregate,
|
|
20
|
+
Alt,
|
|
21
|
+
AltBranch,
|
|
22
|
+
Block,
|
|
23
|
+
BoundedContext,
|
|
24
|
+
Catalog,
|
|
25
|
+
Channel,
|
|
26
|
+
External,
|
|
27
|
+
Field,
|
|
28
|
+
Flow,
|
|
29
|
+
FlowNode,
|
|
30
|
+
Loop,
|
|
31
|
+
Parallel,
|
|
32
|
+
RpcMethod,
|
|
33
|
+
RpcService,
|
|
34
|
+
Service,
|
|
35
|
+
Status,
|
|
36
|
+
Step,
|
|
37
|
+
Store,
|
|
38
|
+
TypeDef,
|
|
39
|
+
} from "./catalog";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A catalog as a FILE may carry it, which is not quite a catalog as a reader
|
|
43
|
+
* gets one: the two stamps are optional here and required downstream.
|
|
44
|
+
*
|
|
45
|
+
* The host stamps what it generates, from the last commit that touched the
|
|
46
|
+
* input the fragment was read from. A file nobody generates - the estate's
|
|
47
|
+
* hand-written facts - is stamped by nobody, and a provenance typed in by hand
|
|
48
|
+
* is a claim no process keeps true: it stays where it was typed while the
|
|
49
|
+
* estate moves on, and being the oldest stamp in the corpus it then dates the
|
|
50
|
+
* whole merged catalog. So a source is allowed to carry no stamp at all, and
|
|
51
|
+
* saying nothing is treated as what it is - an absence of evidence, which
|
|
52
|
+
* drops out of the merge rather than voting in it.
|
|
53
|
+
*/
|
|
54
|
+
export type SourceCatalog = Omit<Catalog, "generatedAt" | "commit"> &
|
|
55
|
+
Partial<Pick<Catalog, "generatedAt" | "commit">>;
|
|
56
|
+
|
|
57
|
+
/** One file, parsed. */
|
|
58
|
+
export interface CatalogSource {
|
|
59
|
+
/** Where it was read from, as a reader would type it. */
|
|
60
|
+
path: string;
|
|
61
|
+
catalog: SourceCatalog;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* When a source was generated and from what. This is per-source and never
|
|
66
|
+
* merged into one number: a catalog whose domain facts are a day old and whose
|
|
67
|
+
* schema facts are a month old is not "three weeks stale", it is two claims of
|
|
68
|
+
* different ages, and averaging them hides the one that matters.
|
|
69
|
+
*
|
|
70
|
+
* Both fields are empty for a source that carries no stamp, so the shape of a
|
|
71
|
+
* stamp does not depend on whether there was one to read.
|
|
72
|
+
*/
|
|
73
|
+
export interface SourceStamp {
|
|
74
|
+
path: string;
|
|
75
|
+
generatedAt: string;
|
|
76
|
+
commit: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MergeConflict {
|
|
80
|
+
/** The source that lost - the one whose value was not taken. */
|
|
81
|
+
path: string;
|
|
82
|
+
/** What the conflict is about, as a catalog id. */
|
|
83
|
+
where: string;
|
|
84
|
+
message: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MergeResult {
|
|
88
|
+
catalog: Catalog;
|
|
89
|
+
sources: SourceStamp[];
|
|
90
|
+
conflicts: MergeConflict[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Merges sources in path order, so the result never depends on the order a
|
|
95
|
+
* glob happened to return.
|
|
96
|
+
*
|
|
97
|
+
* First writer wins for anything scalar, and the loser is recorded rather than
|
|
98
|
+
* dropped silently. Lists are unioned by id.
|
|
99
|
+
*/
|
|
100
|
+
export function mergeCatalogs(sources: CatalogSource[]): MergeResult {
|
|
101
|
+
const ordered = [...sources].sort((a, b) => a.path.localeCompare(b.path));
|
|
102
|
+
const conflicts: MergeConflict[] = [];
|
|
103
|
+
|
|
104
|
+
const contexts = new Map<string, BoundedContext>();
|
|
105
|
+
const contextOrigin = new Map<string, string>();
|
|
106
|
+
const services = new Map<string, Service>();
|
|
107
|
+
const serviceOrigin = new Map<string, string>();
|
|
108
|
+
const defs: Record<string, TypeDef> = {};
|
|
109
|
+
const defOrigin = new Map<string, string>();
|
|
110
|
+
const flows: Catalog["flows"] = [];
|
|
111
|
+
const adrs: Catalog["adrs"] = [];
|
|
112
|
+
const stores: NonNullable<Catalog["stores"]> = [];
|
|
113
|
+
const modules: NonNullable<Catalog["modules"]> = [];
|
|
114
|
+
const terms: NonNullable<Catalog["terms"]> = [];
|
|
115
|
+
const repos: NonNullable<Catalog["repos"]> = [];
|
|
116
|
+
const repoOrigin = new Map<string, string>();
|
|
117
|
+
const externals = new Map<string, External>();
|
|
118
|
+
const externalOrigin = new Map<string, string>();
|
|
119
|
+
const seen = new Map<string, string>(); // flow/adr/store/module/term id -> source path
|
|
120
|
+
|
|
121
|
+
const stamps: SourceStamp[] = ordered.map((source) => ({
|
|
122
|
+
path: source.path,
|
|
123
|
+
generatedAt: source.catalog.generatedAt ?? "",
|
|
124
|
+
commit: source.catalog.commit ?? "",
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
for (const { path, catalog } of ordered) {
|
|
128
|
+
for (const context of catalog.contexts) {
|
|
129
|
+
const existing = contexts.get(context.id);
|
|
130
|
+
if (!existing) {
|
|
131
|
+
// The services list is filled from the merge below, not copied: a
|
|
132
|
+
// service can be described by several sources at once, and the context
|
|
133
|
+
// has to end up holding the merged one rather than the first arrival.
|
|
134
|
+
contexts.set(context.id, { ...context, services: [] });
|
|
135
|
+
contextOrigin.set(context.id, path);
|
|
136
|
+
} else {
|
|
137
|
+
// A second source may add services to a context somebody else named.
|
|
138
|
+
// What it may not do is quietly rename it.
|
|
139
|
+
const owner = contextOrigin.get(context.id) ?? "";
|
|
140
|
+
// First NON-EMPTY wins, not first. A fragment describing one aspect of
|
|
141
|
+
// a service leaves the fields it knows nothing about empty, and it can
|
|
142
|
+
// easily sort before the one that fills them in - so an empty value
|
|
143
|
+
// yields rather than winning and calling the real one a conflict.
|
|
144
|
+
for (const field of ["name", "summary"] as const) {
|
|
145
|
+
const theirs = context[field];
|
|
146
|
+
if (!theirs) continue;
|
|
147
|
+
if (!existing[field]) {
|
|
148
|
+
existing[field] = theirs;
|
|
149
|
+
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (theirs !== existing[field]) {
|
|
153
|
+
conflicts.push({
|
|
154
|
+
path,
|
|
155
|
+
where: context.id,
|
|
156
|
+
message: `context "${context.id}" has ${field} "${theirs}" here and "${existing[field]}" in ${owner}; the first one is used`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Classification is its own line only because it is not a plain string:
|
|
162
|
+
// the same rule, spelled where the type can see it.
|
|
163
|
+
if (context.classification) {
|
|
164
|
+
if (!existing.classification) {
|
|
165
|
+
existing.classification = context.classification;
|
|
166
|
+
} else if (existing.classification !== context.classification) {
|
|
167
|
+
conflicts.push({
|
|
168
|
+
path,
|
|
169
|
+
where: context.id,
|
|
170
|
+
message: `context "${context.id}" is classified ${context.classification} here and ${existing.classification} in ${owner}; the first one is used`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (context.kind) {
|
|
175
|
+
if (!existing.kind) {
|
|
176
|
+
existing.kind = context.kind;
|
|
177
|
+
} else if (existing.kind !== context.kind) {
|
|
178
|
+
conflicts.push({
|
|
179
|
+
path,
|
|
180
|
+
where: context.id,
|
|
181
|
+
message: `context "${context.id}" has kind ${context.kind} here and ${existing.kind} in ${owner}; the first one is used`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const service of context.services) {
|
|
188
|
+
mergeService(services, serviceOrigin, service, path, conflicts);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const [key, def] of Object.entries(catalog.defs)) {
|
|
193
|
+
const existing = defs[key];
|
|
194
|
+
if (existing === undefined) {
|
|
195
|
+
defs[key] = def;
|
|
196
|
+
defOrigin.set(key, path);
|
|
197
|
+
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// Shared types live in one namespace on purpose: `ref` is a bare key, so
|
|
201
|
+
// two sources meaning different things by "Money" is a real problem and
|
|
202
|
+
// not one a prefix would solve - it would only make it invisible.
|
|
203
|
+
if (JSON.stringify(existing) !== JSON.stringify(def)) {
|
|
204
|
+
conflicts.push({
|
|
205
|
+
path,
|
|
206
|
+
where: `defs.${key}`,
|
|
207
|
+
message: `shared type "${key}" is defined differently here than in ${defOrigin.get(key)}; the first one is used`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const flow of catalog.flows) {
|
|
213
|
+
const owner = seen.get(flow.id);
|
|
214
|
+
if (owner === undefined) {
|
|
215
|
+
seen.set(flow.id, path);
|
|
216
|
+
flows.push(flow);
|
|
217
|
+
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// A second declaration of the same flow is allowed to say one thing:
|
|
221
|
+
// that some of its steps have been seen running. That is what a source
|
|
222
|
+
// built from traces knows and a source built from code cannot, and the
|
|
223
|
+
// two meet here, on the step. Anything else about the flow - a lane, a
|
|
224
|
+
// hop, a branch - has to agree, or the second one is the conflict it
|
|
225
|
+
// always was.
|
|
226
|
+
const at = flows.findIndex((f) => f.id === flow.id);
|
|
227
|
+
const raised = overlayFlow(flows[at]!, flow);
|
|
228
|
+
if (raised) {
|
|
229
|
+
flows[at] = raised;
|
|
230
|
+
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
conflicts.push({
|
|
234
|
+
path,
|
|
235
|
+
where: flow.id,
|
|
236
|
+
message: `flow "${flow.id}" is already declared in ${owner}; the one here is ignored`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
for (const adr of catalog.adrs) {
|
|
240
|
+
if (claim(seen, adr.id, path, conflicts, "ADR")) adrs.push(adr);
|
|
241
|
+
}
|
|
242
|
+
for (const store of catalog.stores ?? []) {
|
|
243
|
+
if (claim(seen, store.id, path, conflicts, "store")) stores.push(store);
|
|
244
|
+
}
|
|
245
|
+
// Claimed by id like everything else at this level, and that is exactly why
|
|
246
|
+
// a module's id is its registry-global name rather than one derived from an
|
|
247
|
+
// owner: the producer and each consumer describe the same module from
|
|
248
|
+
// different repositories, and only a shared id makes them one entry here.
|
|
249
|
+
for (const module of catalog.modules ?? []) {
|
|
250
|
+
if (claim(seen, module.id, path, conflicts, "module"))
|
|
251
|
+
modules.push(module);
|
|
252
|
+
}
|
|
253
|
+
// A term's id carries its context, so the same word in two contexts is two
|
|
254
|
+
// entries and merges as two. What collides here is one word defined twice
|
|
255
|
+
// inside ONE context - two services of it keeping glossaries that disagree
|
|
256
|
+
// - which is the failure a glossary is written to prevent, reported where
|
|
257
|
+
// the reader can see both sources rather than settled by file order.
|
|
258
|
+
for (const term of catalog.terms ?? []) {
|
|
259
|
+
if (claim(seen, term.id, path, conflicts, "term")) terms.push(term);
|
|
260
|
+
}
|
|
261
|
+
// Not claimed by id like the rest, because agreeing is the normal case
|
|
262
|
+
// rather than a collision: one fetch step writes one fragment per
|
|
263
|
+
// repository, and two extractors reading two services out of that one copy
|
|
264
|
+
// are describing the same pin. Saying the same thing twice is silence.
|
|
265
|
+
// Saying two different things is the conflict - the copy on disk is at one
|
|
266
|
+
// commit, so a second answer means a fragment left over from a pin that
|
|
267
|
+
// moved, and a link built from it would point at code the reader never saw.
|
|
268
|
+
for (const pin of catalog.repos ?? []) {
|
|
269
|
+
const owner = repoOrigin.get(pin.repo);
|
|
270
|
+
if (owner === undefined) {
|
|
271
|
+
repoOrigin.set(pin.repo, path);
|
|
272
|
+
repos.push(pin);
|
|
273
|
+
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const held = repos.find((r) => r.repo === pin.repo)!;
|
|
277
|
+
if (held.commit === pin.commit) continue;
|
|
278
|
+
conflicts.push({
|
|
279
|
+
path,
|
|
280
|
+
where: pin.repo,
|
|
281
|
+
message: `repository "${pin.repo}" is pinned to ${pin.commit} here and to ${held.commit} in ${owner}; the first one is used`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
// Merged like a service rather than claimed like a store, and for the
|
|
285
|
+
// same reason a service is: one source reads the document a third party
|
|
286
|
+
// publishes and knows what it answers on, another names it and says what
|
|
287
|
+
// it is for, and neither has to know the other exists.
|
|
288
|
+
for (const external of catalog.externals ?? []) {
|
|
289
|
+
mergeExternal(externals, externalOrigin, external, path, conflicts);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Services go back into their contexts once every source has been read, in
|
|
294
|
+
// the order they were first declared.
|
|
295
|
+
for (const service of services.values()) {
|
|
296
|
+
const contextID = service.id.slice(0, service.id.indexOf("."));
|
|
297
|
+
contexts.get(contextID)?.services.push(service);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const merged: Catalog = {
|
|
301
|
+
// The oldest stamp, because a merged catalog is exactly as fresh as its
|
|
302
|
+
// stalest part. Per-source stamps travel alongside for anything that wants
|
|
303
|
+
// to be precise about which part that is - the Overview stamp opens onto
|
|
304
|
+
// them, which is what makes "6 sources" a number a reader can go and read.
|
|
305
|
+
//
|
|
306
|
+
// Only stamped sources are counted. An unstamped one is not the stalest
|
|
307
|
+
// part of anything; it is a part with no age, and a merge that let it vote
|
|
308
|
+
// would report the age of the file nobody generates.
|
|
309
|
+
generatedAt: oldest(stamps.map((s) => s.generatedAt)),
|
|
310
|
+
commit: describeCommits(stamps),
|
|
311
|
+
contexts: [...contexts.values()],
|
|
312
|
+
defs,
|
|
313
|
+
flows,
|
|
314
|
+
adrs,
|
|
315
|
+
};
|
|
316
|
+
if (stores.length > 0) merged.stores = foldMaps(merged, stores);
|
|
317
|
+
if (modules.length > 0) merged.modules = modules;
|
|
318
|
+
if (terms.length > 0) merged.terms = terms;
|
|
319
|
+
if (repos.length > 0) merged.repos = repos;
|
|
320
|
+
if (externals.size > 0) merged.externals = [...externals.values()];
|
|
321
|
+
|
|
322
|
+
return { catalog: merged, sources: stamps, conflicts };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Spells every column's `maps` path the way the aggregate declares the field.
|
|
327
|
+
*
|
|
328
|
+
* An extractor reads the path off the code that writes the row, and what the
|
|
329
|
+
* code says there is the accessor - `q.ID()`, `q.BasketID()` - while the field
|
|
330
|
+
* behind it is `id` or `basketID`. One name in two cases, and the extractor
|
|
331
|
+
* that read the store never saw the domain to know which. Here both have
|
|
332
|
+
* arrived, so the store's spelling is folded onto the domain's when exactly
|
|
333
|
+
* one declared field matches ignoring case. A path that already names a field,
|
|
334
|
+
* or names none, is left as read: the first needs nothing, and the second is
|
|
335
|
+
* a claim the reader should see as the code made it.
|
|
336
|
+
*
|
|
337
|
+
* Returns new stores rather than rewriting the sources' own objects.
|
|
338
|
+
*/
|
|
339
|
+
function foldMaps(catalog: Catalog, stores: Store[]): Store[] {
|
|
340
|
+
const aggregates = new Map<string, Aggregate>();
|
|
341
|
+
for (const context of catalog.contexts) {
|
|
342
|
+
for (const service of context.services) {
|
|
343
|
+
for (const aggregate of service.aggregates) {
|
|
344
|
+
aggregates.set(aggregate.id, aggregate);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The same reading `blockFields` gives a page; repeated rather than
|
|
350
|
+
// imported because this file is loaded by node as it is, without a bundler
|
|
351
|
+
// to resolve a value import of the catalog module.
|
|
352
|
+
const fieldsOf = (block: Block): Field[] =>
|
|
353
|
+
block.fields ?? (block.ref ? catalog.defs[block.ref]?.fields : []) ?? [];
|
|
354
|
+
const blockNamed = (
|
|
355
|
+
aggregate: Aggregate,
|
|
356
|
+
name: string,
|
|
357
|
+
): Block | undefined => {
|
|
358
|
+
const blocks = [...aggregate.valueObjects, ...aggregate.entities];
|
|
359
|
+
return (
|
|
360
|
+
blocks.find((b) => b.name === name) ??
|
|
361
|
+
blocks.find((b) => b.name.toLowerCase() === name.toLowerCase())
|
|
362
|
+
);
|
|
363
|
+
};
|
|
364
|
+
// The block a field's type names, when it names one of this aggregate's:
|
|
365
|
+
// `Scan[]`, `[]line.Line` and `Money | undefined` all point at a block by
|
|
366
|
+
// the last word of the type, whatever the language wrapped it in.
|
|
367
|
+
const blockOf = (aggregate: Aggregate, field: Field): Block | undefined => {
|
|
368
|
+
const word = field.type
|
|
369
|
+
.replace(/\s*\|\s*(undefined|null)\s*$/g, "")
|
|
370
|
+
.replace(/^\[\]|\[\]$/g, "")
|
|
371
|
+
.replace(/^readonly\s+/, "")
|
|
372
|
+
.split(".")
|
|
373
|
+
.pop();
|
|
374
|
+
return word ? blockNamed(aggregate, word) : undefined;
|
|
375
|
+
};
|
|
376
|
+
const fieldNamed = (fields: Field[], name: string): Field | undefined => {
|
|
377
|
+
const exact = fields.find((f) => f.name === name);
|
|
378
|
+
if (exact) return exact;
|
|
379
|
+
const same = fields.filter(
|
|
380
|
+
(f) => f.name.toLowerCase() === name.toLowerCase(),
|
|
381
|
+
);
|
|
382
|
+
return same.length === 1 ? same[0] : undefined;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const fold = (aggregate: Aggregate, maps: string): string => {
|
|
386
|
+
const [head, ...path] = maps.split(".");
|
|
387
|
+
if (!head || path.length === 0) return maps;
|
|
388
|
+
let block = blockNamed(aggregate, head);
|
|
389
|
+
if (!block) return maps;
|
|
390
|
+
const spelled: string[] = [block.name];
|
|
391
|
+
for (const segment of path) {
|
|
392
|
+
const field = block ? fieldNamed(fieldsOf(block), segment) : undefined;
|
|
393
|
+
if (!field) {
|
|
394
|
+
// Nothing declared matches from here on: the rest stays as read,
|
|
395
|
+
// a claim for the reader to see rather than for the merge to hide.
|
|
396
|
+
spelled.push(segment);
|
|
397
|
+
block = undefined;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
spelled.push(field.name);
|
|
401
|
+
block = blockOf(aggregate, field);
|
|
402
|
+
}
|
|
403
|
+
return spelled.join(".");
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
return stores.map((store) => ({
|
|
407
|
+
...store,
|
|
408
|
+
tables: store.tables.map((table) => {
|
|
409
|
+
const aggregate = table.persists?.aggregate
|
|
410
|
+
? aggregates.get(table.persists.aggregate)
|
|
411
|
+
: undefined;
|
|
412
|
+
if (!aggregate) return table;
|
|
413
|
+
return {
|
|
414
|
+
...table,
|
|
415
|
+
columns: table.columns.map((column) => {
|
|
416
|
+
if (!column.maps) return column;
|
|
417
|
+
const maps = fold(aggregate, column.maps);
|
|
418
|
+
return maps === column.maps ? column : { ...column, maps };
|
|
419
|
+
}),
|
|
420
|
+
};
|
|
421
|
+
}),
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Merges one service into the set.
|
|
427
|
+
*
|
|
428
|
+
* This is what lets a service be described by more than one source at a time,
|
|
429
|
+
* which is the whole point of extracting each aspect separately: one generator
|
|
430
|
+
* reads the domain and knows the aggregates, another reads the OpenAPI spec and
|
|
431
|
+
* knows what the service answers, and neither has to know about the other.
|
|
432
|
+
*
|
|
433
|
+
* Lists are unioned by id; scalars keep the first value and record the loser.
|
|
434
|
+
*/
|
|
435
|
+
function mergeService(
|
|
436
|
+
services: Map<string, Service>,
|
|
437
|
+
origin: Map<string, string>,
|
|
438
|
+
incoming: Service,
|
|
439
|
+
path: string,
|
|
440
|
+
conflicts: MergeConflict[],
|
|
441
|
+
): void {
|
|
442
|
+
const existing = services.get(incoming.id);
|
|
443
|
+
if (!existing) {
|
|
444
|
+
services.set(incoming.id, {
|
|
445
|
+
...incoming,
|
|
446
|
+
provides: [...incoming.provides],
|
|
447
|
+
consumes: [...incoming.consumes],
|
|
448
|
+
aggregates: incoming.aggregates.map(copyAggregate),
|
|
449
|
+
...(incoming.stores ? { stores: [...incoming.stores] } : {}),
|
|
450
|
+
...(incoming.modules ? { modules: [...incoming.modules] } : {}),
|
|
451
|
+
...(incoming.channels
|
|
452
|
+
? { channels: incoming.channels.map(copyChannel) }
|
|
453
|
+
: {}),
|
|
454
|
+
...(incoming.owners ? { owners: [...incoming.owners] } : {}),
|
|
455
|
+
...(incoming.technologies
|
|
456
|
+
? { technologies: [...incoming.technologies] }
|
|
457
|
+
: {}),
|
|
458
|
+
...(incoming.commands
|
|
459
|
+
? { commands: incoming.commands.map((c) => ({ ...c })) }
|
|
460
|
+
: {}),
|
|
461
|
+
});
|
|
462
|
+
origin.set(incoming.id, path);
|
|
463
|
+
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const owner = origin.get(incoming.id) ?? "";
|
|
468
|
+
for (const field of ["name", "repo", "path", "readme"] as const) {
|
|
469
|
+
const theirs = incoming[field];
|
|
470
|
+
if (!theirs) continue;
|
|
471
|
+
if (!existing[field]) {
|
|
472
|
+
existing[field] = theirs;
|
|
473
|
+
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (theirs !== existing[field]) {
|
|
477
|
+
conflicts.push({
|
|
478
|
+
path,
|
|
479
|
+
where: incoming.id,
|
|
480
|
+
message: `service "${incoming.id}" has a different ${field} here than in ${owner}; the first one is used`,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (incoming.kind) {
|
|
486
|
+
if (!existing.kind) {
|
|
487
|
+
existing.kind = incoming.kind;
|
|
488
|
+
} else if (existing.kind !== incoming.kind) {
|
|
489
|
+
conflicts.push({
|
|
490
|
+
path,
|
|
491
|
+
where: incoming.id,
|
|
492
|
+
message: `service "${incoming.id}" has kind ${incoming.kind} here and ${existing.kind} in ${owner}; the first one is used`,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (incoming.technologies?.length) {
|
|
498
|
+
const technologies = existing.technologies ?? [];
|
|
499
|
+
for (const technology of incoming.technologies) {
|
|
500
|
+
if (!technologies.includes(technology)) technologies.push(technology);
|
|
501
|
+
}
|
|
502
|
+
existing.technologies = technologies;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
mergeInterfaces(existing.provides, incoming.provides);
|
|
506
|
+
appendNew(existing.consumes, incoming.consumes, (c) => c.id, raise);
|
|
507
|
+
mergeAggregates(existing.aggregates, incoming.aggregates);
|
|
508
|
+
|
|
509
|
+
if (incoming.stores?.length) {
|
|
510
|
+
const stores = existing.stores ?? [];
|
|
511
|
+
for (const id of incoming.stores) if (!stores.includes(id)) stores.push(id);
|
|
512
|
+
existing.stores = stores;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (incoming.modules?.length) {
|
|
516
|
+
const modules = existing.modules ?? [];
|
|
517
|
+
for (const id of incoming.modules)
|
|
518
|
+
if (!modules.includes(id)) modules.push(id);
|
|
519
|
+
existing.modules = modules;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Unioned like stores and modules, and for the same reason: two sources
|
|
523
|
+
// naming owners are two rules that both matched, not two answers to one
|
|
524
|
+
// question. A service owned by the platform team and by the team that wrote
|
|
525
|
+
// it is owned by both, which is what the file says and what a reviewer sees.
|
|
526
|
+
if (incoming.owners?.length) {
|
|
527
|
+
const owners = existing.owners ?? [];
|
|
528
|
+
for (const handle of incoming.owners) {
|
|
529
|
+
if (!owners.includes(handle)) owners.push(handle);
|
|
530
|
+
}
|
|
531
|
+
existing.owners = owners;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Keyed by the line a reader types, which is the whole of a command's
|
|
535
|
+
// identity: two fragments listing `make test` read one Makefile, and a
|
|
536
|
+
// second row would be the same command offered twice. Not a conflict
|
|
537
|
+
// worth reporting when the doc or body differ, either - the first one
|
|
538
|
+
// read is kept, the way scalars are.
|
|
539
|
+
if (incoming.commands?.length) {
|
|
540
|
+
const commands = existing.commands ?? [];
|
|
541
|
+
for (const command of incoming.commands) {
|
|
542
|
+
if (!commands.some((c) => c.run === command.run)) {
|
|
543
|
+
commands.push({ ...command });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
existing.commands = commands;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (incoming.channels?.length) {
|
|
550
|
+
// Keyed by address, because that is the whole of a channel's identity. Two
|
|
551
|
+
// documents describing one channel is not a conflict worth reporting - a
|
|
552
|
+
// service may split its bus across several - so the first one keeps the
|
|
553
|
+
// messages it declared and the second adds the ones it knows about.
|
|
554
|
+
const channels = existing.channels ?? [];
|
|
555
|
+
appendNew(
|
|
556
|
+
channels,
|
|
557
|
+
incoming.channels.map(copyChannel),
|
|
558
|
+
(c) => c.address,
|
|
559
|
+
(mine, theirs) => {
|
|
560
|
+
if (!mine.kind && theirs.kind) {
|
|
561
|
+
mine.kind = theirs.kind;
|
|
562
|
+
} else if (mine.kind && theirs.kind && mine.kind !== theirs.kind) {
|
|
563
|
+
conflicts.push({
|
|
564
|
+
path,
|
|
565
|
+
where: incoming.id,
|
|
566
|
+
message: `channel "${mine.address}" of service "${incoming.id}" has kind ${theirs.kind} here and ${mine.kind} in ${owner}; the first one is used`,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
appendNew(
|
|
570
|
+
mine.messages,
|
|
571
|
+
theirs.messages,
|
|
572
|
+
(m) => `${m.direction} ${m.name}`,
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
// Mutated in place: the channel that was here keeps its prose and its
|
|
576
|
+
// source, and gains the messages the other document named.
|
|
577
|
+
return undefined;
|
|
578
|
+
},
|
|
579
|
+
);
|
|
580
|
+
existing.channels = channels;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function copyChannel(channel: Channel): Channel {
|
|
585
|
+
return { ...channel, messages: [...channel.messages] };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Merges one external into the set, the way a service is merged: interfaces
|
|
590
|
+
* unioned by id, scalars keeping the first non-empty value with the loser
|
|
591
|
+
* recorded. There is less to merge - no aggregates, no consumes, no stores -
|
|
592
|
+
* because there is less the catalog may claim about a system it does not own.
|
|
593
|
+
*/
|
|
594
|
+
function mergeExternal(
|
|
595
|
+
externals: Map<string, External>,
|
|
596
|
+
origin: Map<string, string>,
|
|
597
|
+
incoming: External,
|
|
598
|
+
path: string,
|
|
599
|
+
conflicts: MergeConflict[],
|
|
600
|
+
): void {
|
|
601
|
+
const existing = externals.get(incoming.id);
|
|
602
|
+
if (!existing) {
|
|
603
|
+
externals.set(incoming.id, {
|
|
604
|
+
...incoming,
|
|
605
|
+
provides: [...incoming.provides],
|
|
606
|
+
});
|
|
607
|
+
origin.set(incoming.id, path);
|
|
608
|
+
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const owner = origin.get(incoming.id) ?? "";
|
|
613
|
+
for (const field of ["name", "summary", "url"] as const) {
|
|
614
|
+
const theirs = incoming[field];
|
|
615
|
+
if (!theirs) continue;
|
|
616
|
+
if (!existing[field]) {
|
|
617
|
+
existing[field] = theirs;
|
|
618
|
+
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (theirs !== existing[field]) {
|
|
622
|
+
conflicts.push({
|
|
623
|
+
path,
|
|
624
|
+
where: incoming.id,
|
|
625
|
+
message: `external "${incoming.id}" has a different ${field} here than in ${owner}; the first one is used`,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
mergeInterfaces(existing.provides, incoming.provides);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* An interface is commonly known by two independent sources: a contract
|
|
635
|
+
* extractor carries every method and message shape, while a call-site
|
|
636
|
+
* extractor carries only the methods this repository actually invokes. They
|
|
637
|
+
* share an id on purpose, so the second source enriches the first regardless
|
|
638
|
+
* of filename order instead of losing a whole WSDL/OpenAPI shape.
|
|
639
|
+
*/
|
|
640
|
+
function mergeInterfaces(target: RpcService[], incoming: RpcService[]): void {
|
|
641
|
+
for (const theirs of incoming) {
|
|
642
|
+
const mine = target.find((provided) => provided.id === theirs.id);
|
|
643
|
+
if (!mine) {
|
|
644
|
+
target.push(copyInterface(theirs));
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (!mine.source && theirs.source) mine.source = theirs.source;
|
|
649
|
+
if (!mine.module && theirs.module) mine.module = theirs.module;
|
|
650
|
+
for (const method of theirs.methods) {
|
|
651
|
+
const existing = mine.methods.find((candidate) => candidate.name === method.name);
|
|
652
|
+
if (!existing) {
|
|
653
|
+
mine.methods.push(copyMethod(method));
|
|
654
|
+
} else {
|
|
655
|
+
enrichMethod(existing, method);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const messages = mine.messages ?? [];
|
|
660
|
+
for (const message of theirs.messages ?? []) {
|
|
661
|
+
const existing = messages.find((candidate) => candidate.name === message.name);
|
|
662
|
+
if (!existing) {
|
|
663
|
+
messages.push({
|
|
664
|
+
...message,
|
|
665
|
+
fields: message.fields.map((field) => ({ ...field })),
|
|
666
|
+
...(message.discriminator
|
|
667
|
+
? {
|
|
668
|
+
discriminator: {
|
|
669
|
+
...message.discriminator,
|
|
670
|
+
variants: message.discriminator.variants.map((variant) => ({ ...variant })),
|
|
671
|
+
},
|
|
672
|
+
}
|
|
673
|
+
: {}),
|
|
674
|
+
});
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
appendNew(existing.fields, message.fields.map((field) => ({ ...field })), (field) => field.name);
|
|
678
|
+
if (!existing.discriminator && message.discriminator) {
|
|
679
|
+
existing.discriminator = {
|
|
680
|
+
...message.discriminator,
|
|
681
|
+
variants: message.discriminator.variants.map((variant) => ({ ...variant })),
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (messages.length > 0) mine.messages = messages;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function copyInterface(provided: RpcService): RpcService {
|
|
690
|
+
return {
|
|
691
|
+
...provided,
|
|
692
|
+
methods: provided.methods.map(copyMethod),
|
|
693
|
+
...(provided.messages
|
|
694
|
+
? {
|
|
695
|
+
messages: provided.messages.map((message) => ({
|
|
696
|
+
...message,
|
|
697
|
+
fields: message.fields.map((field) => ({ ...field })),
|
|
698
|
+
...(message.discriminator
|
|
699
|
+
? {
|
|
700
|
+
discriminator: {
|
|
701
|
+
...message.discriminator,
|
|
702
|
+
variants: message.discriminator.variants.map((variant) => ({ ...variant })),
|
|
703
|
+
},
|
|
704
|
+
}
|
|
705
|
+
: {}),
|
|
706
|
+
})),
|
|
707
|
+
}
|
|
708
|
+
: {}),
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function copyMethod(method: RpcMethod): RpcMethod {
|
|
713
|
+
return {
|
|
714
|
+
...method,
|
|
715
|
+
...(method.http ? { http: { ...method.http } } : {}),
|
|
716
|
+
...(method.soap
|
|
717
|
+
? {
|
|
718
|
+
soap: {
|
|
719
|
+
...method.soap,
|
|
720
|
+
...(method.soap.faults ? { faults: [...method.soap.faults] } : {}),
|
|
721
|
+
...(method.soap.headers ? { headers: [...method.soap.headers] } : {}),
|
|
722
|
+
},
|
|
723
|
+
}
|
|
724
|
+
: {}),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function enrichMethod(mine: RpcMethod, theirs: RpcMethod): void {
|
|
729
|
+
for (const field of ["doc", "request", "requestRef", "response", "responseRef", "streaming"] as const) {
|
|
730
|
+
if (!mine[field] && theirs[field]) mine[field] = theirs[field] as never;
|
|
731
|
+
}
|
|
732
|
+
if (!mine.deprecated && theirs.deprecated) mine.deprecated = true;
|
|
733
|
+
if (!mine.http && theirs.http) mine.http = { ...theirs.http };
|
|
734
|
+
if (!mine.soap && theirs.soap) {
|
|
735
|
+
mine.soap = copyMethod(theirs).soap;
|
|
736
|
+
} else if (mine.soap && theirs.soap) {
|
|
737
|
+
for (const field of ["action", "version", "style", "endpoint", "binding"] as const) {
|
|
738
|
+
if (!mine.soap[field] && theirs.soap[field]) mine.soap[field] = theirs.soap[field] as never;
|
|
739
|
+
}
|
|
740
|
+
mine.soap.faults = [...new Set([...(mine.soap.faults ?? []), ...(theirs.soap.faults ?? [])])];
|
|
741
|
+
mine.soap.headers = [...new Set([...(mine.soap.headers ?? []), ...(theirs.soap.headers ?? [])])];
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Unions aggregates by id, and inside a shared aggregate unions events by id,
|
|
747
|
+
* and inside a shared event unions consumers by service.
|
|
748
|
+
*
|
|
749
|
+
* Union stops one level short of the scalars on purpose. Two sources both
|
|
750
|
+
* describing `shop.oms.order` are the domain extractor and something that
|
|
751
|
+
* knows one more thing about it - typically who listens to its events, which
|
|
752
|
+
* is a fact the producer's repository cannot hold. The fields they both fill
|
|
753
|
+
* in keep the first value like everything else here; what the second one adds
|
|
754
|
+
* is kept rather than thrown away with the whole aggregate.
|
|
755
|
+
*/
|
|
756
|
+
function mergeAggregates(
|
|
757
|
+
into: Aggregate[],
|
|
758
|
+
from: Aggregate[],
|
|
759
|
+
): void {
|
|
760
|
+
const byId = new Map(into.map((a) => [a.id, a]));
|
|
761
|
+
for (const incoming of from) {
|
|
762
|
+
const existing = byId.get(incoming.id);
|
|
763
|
+
if (!existing) {
|
|
764
|
+
const copy = copyAggregate(incoming);
|
|
765
|
+
into.push(copy);
|
|
766
|
+
byId.set(copy.id, copy);
|
|
767
|
+
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
const events = new Map(existing.events.map((e) => [e.id, e]));
|
|
771
|
+
for (const event of incoming.events) {
|
|
772
|
+
const known = events.get(event.id);
|
|
773
|
+
if (!known) {
|
|
774
|
+
const copy = { ...event, consumers: [...event.consumers] };
|
|
775
|
+
existing.events.push(copy);
|
|
776
|
+
events.set(copy.id, copy);
|
|
777
|
+
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
appendNew(known.consumers, event.consumers, (c) => c.service, raise);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** A copy deep enough that unioning into it never writes into a source. */
|
|
786
|
+
function copyAggregate(aggregate: Aggregate): Aggregate {
|
|
787
|
+
return {
|
|
788
|
+
...aggregate,
|
|
789
|
+
events: aggregate.events.map((e) => ({ ...e, consumers: [...e.consumers] })),
|
|
790
|
+
...(aggregate.enums ? { enums: [...aggregate.enums] } : {}),
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Appends the entries whose id is not already present. An entry that IS
|
|
796
|
+
* present is left alone, unless `meet` is given: then the two are handed to
|
|
797
|
+
* it, because for a few kinds of entry a second source can add to the first
|
|
798
|
+
* without contradicting it.
|
|
799
|
+
*/
|
|
800
|
+
function appendNew<T>(
|
|
801
|
+
into: T[],
|
|
802
|
+
from: T[],
|
|
803
|
+
id: (item: T) => string,
|
|
804
|
+
meet?: (existing: T, incoming: T) => T | undefined,
|
|
805
|
+
): void {
|
|
806
|
+
const at = new Map(into.map((item, i) => [id(item), i]));
|
|
807
|
+
for (const item of from) {
|
|
808
|
+
const i = at.get(id(item));
|
|
809
|
+
if (i !== undefined) {
|
|
810
|
+
const replaced = meet?.(into[i]!, item);
|
|
811
|
+
if (replaced !== undefined) into[i] = replaced;
|
|
812
|
+
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
at.set(id(item), into.length);
|
|
816
|
+
into.push(item);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Where two sources describe the same edge, `verified` wins. A code reader
|
|
822
|
+
* says a call is declared; a trace says it happened; the second does not
|
|
823
|
+
* contradict the first, it knows more. Nothing else about the edge moves:
|
|
824
|
+
* the first note stays unless there was none.
|
|
825
|
+
*/
|
|
826
|
+
function raise<T extends { status: Status; note?: string }>(
|
|
827
|
+
existing: T,
|
|
828
|
+
incoming: T,
|
|
829
|
+
): T | undefined {
|
|
830
|
+
if (incoming.status !== "verified" || existing.status === "verified") return undefined;
|
|
831
|
+
const raised = { ...existing, status: "verified" as Status };
|
|
832
|
+
if (!raised.note && incoming.note) raised.note = incoming.note;
|
|
833
|
+
|
|
834
|
+
return raised;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* The same flow, declared twice, where the second declaration differs only in
|
|
839
|
+
* what it can vouch for. Returns a copy of the first with every step the
|
|
840
|
+
* second marks `verified` raised, or undefined when the two disagree about
|
|
841
|
+
* anything but status - a different lane, hop, branch or ref - in which case
|
|
842
|
+
* the second is the ordinary conflict.
|
|
843
|
+
*
|
|
844
|
+
* Only `declared` is raised. `unresolved` means the far end is not in the
|
|
845
|
+
* catalog, and a trace showing the hop does not put it there.
|
|
846
|
+
*/
|
|
847
|
+
export function overlayFlow(existing: Flow, incoming: Flow): Flow | undefined {
|
|
848
|
+
const lanes = (flow: Flow) => flow.participants.map((p) => p.id).join(" ");
|
|
849
|
+
if (lanes(existing) !== lanes(incoming)) return undefined;
|
|
850
|
+
|
|
851
|
+
const steps = overlayNodes(existing.steps, incoming.steps);
|
|
852
|
+
if (!steps) return undefined;
|
|
853
|
+
|
|
854
|
+
return { ...existing, steps };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function overlayNodes(
|
|
858
|
+
existing: FlowNode[],
|
|
859
|
+
incoming: FlowNode[],
|
|
860
|
+
): FlowNode[] | undefined {
|
|
861
|
+
if (existing.length !== incoming.length) return undefined;
|
|
862
|
+
const out: FlowNode[] = [];
|
|
863
|
+
for (let i = 0; i < existing.length; i++) {
|
|
864
|
+
const a = existing[i]!;
|
|
865
|
+
const b = incoming[i]!;
|
|
866
|
+
if (a.type !== b.type || a.id !== b.id) return undefined;
|
|
867
|
+
switch (a.type) {
|
|
868
|
+
case "step": {
|
|
869
|
+
const step = b as Step;
|
|
870
|
+
if (
|
|
871
|
+
a.from !== step.from ||
|
|
872
|
+
a.to !== step.to ||
|
|
873
|
+
a.kind !== step.kind ||
|
|
874
|
+
(a.ref ?? "") !== (step.ref ?? "")
|
|
875
|
+
)
|
|
876
|
+
return undefined;
|
|
877
|
+
if (step.status === "verified" && a.status === "declared") {
|
|
878
|
+
out.push({ ...a, status: "verified", note: a.note || step.note });
|
|
879
|
+
} else {
|
|
880
|
+
out.push(a);
|
|
881
|
+
}
|
|
882
|
+
break;
|
|
883
|
+
}
|
|
884
|
+
case "parallel": {
|
|
885
|
+
const other = b as Parallel;
|
|
886
|
+
if (a.branches.length !== other.branches.length) return undefined;
|
|
887
|
+
const branches: FlowNode[][] = [];
|
|
888
|
+
for (let j = 0; j < a.branches.length; j++) {
|
|
889
|
+
const branch = overlayNodes(a.branches[j]!, other.branches[j]!);
|
|
890
|
+
if (!branch) return undefined;
|
|
891
|
+
branches.push(branch);
|
|
892
|
+
}
|
|
893
|
+
out.push({ ...a, branches });
|
|
894
|
+
break;
|
|
895
|
+
}
|
|
896
|
+
case "alt": {
|
|
897
|
+
const other = b as Alt;
|
|
898
|
+
if (a.branches.length !== other.branches.length) return undefined;
|
|
899
|
+
const branches: AltBranch[] = [];
|
|
900
|
+
for (let j = 0; j < a.branches.length; j++) {
|
|
901
|
+
const mine = a.branches[j]!;
|
|
902
|
+
const theirs = other.branches[j]!;
|
|
903
|
+
if (mine.title !== theirs.title) return undefined;
|
|
904
|
+
const steps = overlayNodes(mine.steps, theirs.steps);
|
|
905
|
+
if (!steps) return undefined;
|
|
906
|
+
branches.push({ ...mine, steps });
|
|
907
|
+
}
|
|
908
|
+
out.push({ ...a, branches });
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
911
|
+
case "loop": {
|
|
912
|
+
const other = b as Loop;
|
|
913
|
+
if (a.title !== other.title) return undefined;
|
|
914
|
+
const steps = overlayNodes(a.steps, other.steps);
|
|
915
|
+
if (!steps) return undefined;
|
|
916
|
+
out.push({ ...a, steps });
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return out;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Records who owns an id. Returns false when somebody already did, which is
|
|
927
|
+
* how a duplicate is skipped rather than appended twice.
|
|
928
|
+
*/
|
|
929
|
+
function claim(
|
|
930
|
+
owners: Map<string, string>,
|
|
931
|
+
id: string,
|
|
932
|
+
path: string,
|
|
933
|
+
conflicts: MergeConflict[],
|
|
934
|
+
kind: string,
|
|
935
|
+
): boolean {
|
|
936
|
+
const owner = owners.get(id);
|
|
937
|
+
if (owner === undefined) {
|
|
938
|
+
owners.set(id, path);
|
|
939
|
+
|
|
940
|
+
return true;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
conflicts.push({
|
|
944
|
+
path,
|
|
945
|
+
where: id,
|
|
946
|
+
message: `${kind} "${id}" is already declared in ${owner}; the one here is ignored`,
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* The earliest instant among the stamps, returned as the string the source
|
|
954
|
+
* itself wrote.
|
|
955
|
+
*
|
|
956
|
+
* Instants, not text. The corpus mixes offsets - the host writes `%cI`, so a
|
|
957
|
+
* fragment stamped in Bangkok says `+07:00` and one stamped on a runner says
|
|
958
|
+
* `Z` - and sorting ISO strings compares wall clocks while ignoring the offset
|
|
959
|
+
* that says which wall. `2026-09-05T05:57:24+07:00` sorts after
|
|
960
|
+
* `2026-09-05T00:00:00Z` and happened five hours before it.
|
|
961
|
+
*
|
|
962
|
+
* The winner keeps its own text rather than a normalised rendering of it,
|
|
963
|
+
* because this string is displayed and a reader comparing the header against
|
|
964
|
+
* the fragment on disk should find the same characters in both.
|
|
965
|
+
*
|
|
966
|
+
* A stamp nobody can parse does not win. It is not evidence of being old - it
|
|
967
|
+
* is evidence of being broken - and the alternative, letting it sort first,
|
|
968
|
+
* would date the whole estate from one corrupt file. It is still returned when
|
|
969
|
+
* NOTHING parses, because then it is all there is to show, and a garbled date
|
|
970
|
+
* on the page says "look at the stamps" where an empty one would fail the load
|
|
971
|
+
* with "generatedAt is missing" about a stamp that is plainly present.
|
|
972
|
+
*/
|
|
973
|
+
function oldest(stamps: string[]): string {
|
|
974
|
+
const known = stamps.filter(Boolean);
|
|
975
|
+
const dated = known
|
|
976
|
+
.map((iso) => ({ iso, at: Date.parse(iso) }))
|
|
977
|
+
.filter((s) => !Number.isNaN(s.at))
|
|
978
|
+
.sort((a, b) => a.at - b.at);
|
|
979
|
+
|
|
980
|
+
return dated[0]?.iso ?? known[0] ?? "";
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* What to show where a single commit used to be. One source keeps its sha; more
|
|
985
|
+
* than one, all agreeing, keeps it too - and a genuine spread says so rather
|
|
986
|
+
* than picking a winner and looking precise.
|
|
987
|
+
*
|
|
988
|
+
* The count is of distinct commits, not of files: twenty-eight fragments
|
|
989
|
+
* written by one commit are one source of facts, and saying "28" would make a
|
|
990
|
+
* reader think the estate came from twenty-eight places. A source nobody
|
|
991
|
+
* stamped is not one of them.
|
|
992
|
+
*/
|
|
993
|
+
function describeCommits(stamps: SourceStamp[]): string {
|
|
994
|
+
const commits = [...new Set(stamps.map((s) => s.commit).filter(Boolean))];
|
|
995
|
+
if (commits.length === 1) return commits[0] ?? "";
|
|
996
|
+
if (commits.length === 0) return "";
|
|
997
|
+
|
|
998
|
+
return `${commits.length} sources`;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** Every service in the merged catalog, flattened. Callers want this constantly. */
|
|
1002
|
+
export function services(catalog: Catalog): Service[] {
|
|
1003
|
+
return catalog.contexts.flatMap((context) => context.services);
|
|
1004
|
+
}
|