@shortlink-org/portolan 0.2.3 → 0.3.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 +23 -0
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1066 -0
- package/catalog/roundtrip_test.go +203 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +8 -0
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +14 -0
- package/go.sum +20 -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 +22 -10
- package/plugin/describe.go +118 -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 +114 -46
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-django/README.md +39 -17
- package/plugins/extract-django/domain.py +28 -17
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +55 -2
- package/plugins/extract-django/lifecycle.py +2 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- 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-http-clients/describe.go +19 -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/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-python-kafka/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -4
- package/plugins/extract-python-kafka/extract_test.py +18 -2
- 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/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- package/plugins/pyplugin/protocol.py +1 -5
- package/portolan.json +3 -2
- package/schema/portolan.schema.json +34 -0
- package/scripts/README.md +18 -12
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +21 -11
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +79 -21
- package/scripts/gen-likec4.test.mjs +25 -2
- package/scripts/gen.mjs +118 -115
- 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-git.mjs +77 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/local-api.mjs +71 -4
- package/scripts/local-api.test.mjs +63 -4
- package/scripts/local-discovery.mjs +82 -9
- package/scripts/manifest.mjs +5 -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 +22 -2
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +39 -5
- package/scripts/schema.mjs +29 -0
- package/scripts/warning-policy.mjs +161 -0
- package/scripts/warning-policy.test.mjs +56 -0
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/app/Sidebar.tsx +3 -3
- 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-model.ts +70 -6
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +52 -2
- package/src/catalog.test.ts +13 -2
- 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.messagepack.test.tsx +28 -0
- package/src/components/ChannelRows.test.tsx +54 -0
- package/src/components/ChannelRows.tsx +57 -10
- 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/LifecycleDiagram.tsx +28 -12
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/ProblemRow.tsx +4 -0
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +336 -6
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +104 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/index.css +44 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +2 -59
- package/src/lib/backlinks.test.ts +16 -1
- package/src/lib/backlinks.ts +20 -0
- package/src/lib/catalog-diff.test.ts +18 -0
- package/src/lib/catalog-diff.ts +20 -2
- package/src/lib/derive.ts +1 -0
- 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/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/local-api.ts +20 -2
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/warnings.test.ts +54 -0
- package/src/lib/warnings.ts +260 -0
- package/src/lib/wire-problems.test.ts +21 -0
- package/src/lib/wire-problems.ts +62 -1
- package/src/likec4/FlowView.tsx +2 -6
- package/src/likec4/flow-edges.test.ts +64 -1
- package/src/likec4/flow-edges.ts +43 -7
- package/src/likec4/view-index.ts +8 -2
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge.test.ts +23 -0
- package/src/merge.ts +33 -10
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +200 -44
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/pages/settings/IntegrationsSettings.tsx +117 -0
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +61 -1
- 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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
package catalog
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"sort"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// TestRoundTrip is what holds this package to src/catalog.ts.
|
|
13
|
+
//
|
|
14
|
+
// Every real catalog in the repository is unmarshalled into these structs and
|
|
15
|
+
// marshalled straight back out. Anything the mirror does not know about is
|
|
16
|
+
// dropped on the way in and missing on the way out, so a field added to the
|
|
17
|
+
// TypeScript and forgotten here fails as a named path rather than as a blank
|
|
18
|
+
// section in somebody's generated documentation.
|
|
19
|
+
//
|
|
20
|
+
// The comparison is semantic: key order differs by construction, and an
|
|
21
|
+
// optional list that was written as [] is the same fact as one left out.
|
|
22
|
+
func TestRoundTrip(t *testing.T) {
|
|
23
|
+
files, err := filepath.Glob("../data/*.json")
|
|
24
|
+
if err != nil {
|
|
25
|
+
t.Fatal(err)
|
|
26
|
+
}
|
|
27
|
+
if len(files) == 0 {
|
|
28
|
+
t.Fatal("no catalogs under ../data - this test proves nothing without one")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for _, path := range files {
|
|
32
|
+
t.Run(filepath.Base(path), func(t *testing.T) {
|
|
33
|
+
raw, err := os.ReadFile(path)
|
|
34
|
+
if err != nil {
|
|
35
|
+
t.Fatal(err)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
var before any
|
|
39
|
+
if err := json.Unmarshal(raw, &before); err != nil {
|
|
40
|
+
t.Fatalf("source is not JSON: %v", err)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
var parsed Catalog
|
|
44
|
+
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
45
|
+
t.Fatalf("catalog does not fit the mirror: %v", err)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
out, err := json.Marshal(&parsed)
|
|
49
|
+
if err != nil {
|
|
50
|
+
t.Fatalf("mirror does not marshal: %v", err)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
var after any
|
|
54
|
+
if err := json.Unmarshal(out, &after); err != nil {
|
|
55
|
+
t.Fatal(err)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
diffs := compare("catalog", normalize(before), normalize(after))
|
|
59
|
+
if len(diffs) == 0 {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
shown := diffs
|
|
64
|
+
if len(shown) > 20 {
|
|
65
|
+
shown = shown[:20]
|
|
66
|
+
}
|
|
67
|
+
for _, d := range shown {
|
|
68
|
+
t.Error(d)
|
|
69
|
+
}
|
|
70
|
+
if len(diffs) > len(shown) {
|
|
71
|
+
t.Errorf("... and %d more", len(diffs)-len(shown))
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func TestChannelMessageWireFormatRoundTrip(t *testing.T) {
|
|
78
|
+
before := ChannelMessage{
|
|
79
|
+
Name: "cart.BasketCreated", Direction: ChannelSend,
|
|
80
|
+
Encoding: "msgpack", ContentType: "application/msgpack",
|
|
81
|
+
}
|
|
82
|
+
raw, err := json.Marshal(before)
|
|
83
|
+
if err != nil {
|
|
84
|
+
t.Fatal(err)
|
|
85
|
+
}
|
|
86
|
+
var after ChannelMessage
|
|
87
|
+
if err := json.Unmarshal(raw, &after); err != nil {
|
|
88
|
+
t.Fatal(err)
|
|
89
|
+
}
|
|
90
|
+
if after.Encoding != before.Encoding || after.ContentType != before.ContentType {
|
|
91
|
+
t.Fatalf("wire format changed: %+v", after)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// normalize drops keys whose value carries no information. An optional list
|
|
96
|
+
// written as [] and one left out entirely say the same thing, and holding the
|
|
97
|
+
// mirror to which of the two it produces would test encoding/json rather than
|
|
98
|
+
// the shape.
|
|
99
|
+
func normalize(v any) any {
|
|
100
|
+
switch t := v.(type) {
|
|
101
|
+
case map[string]any:
|
|
102
|
+
out := make(map[string]any, len(t))
|
|
103
|
+
for k, item := range t {
|
|
104
|
+
switch inner := item.(type) {
|
|
105
|
+
case []any:
|
|
106
|
+
if len(inner) == 0 {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
case map[string]any:
|
|
110
|
+
if len(inner) == 0 {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
out[k] = normalize(item)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return out
|
|
118
|
+
case []any:
|
|
119
|
+
out := make([]any, len(t))
|
|
120
|
+
for i, item := range t {
|
|
121
|
+
out[i] = normalize(item)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return out
|
|
125
|
+
default:
|
|
126
|
+
return v
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// compare walks both trees together and names every place they part, as a path
|
|
131
|
+
// a reader can find in the file.
|
|
132
|
+
func compare(path string, before, after any) []string {
|
|
133
|
+
switch b := before.(type) {
|
|
134
|
+
case map[string]any:
|
|
135
|
+
a, ok := after.(map[string]any)
|
|
136
|
+
if !ok {
|
|
137
|
+
return []string{fmt.Sprintf("%s: object in the source, %T after the round trip", path, after)}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
var diffs []string
|
|
141
|
+
for _, key := range sortedKeys(b, a) {
|
|
142
|
+
bv, inSource := b[key]
|
|
143
|
+
av, inMirror := a[key]
|
|
144
|
+
switch {
|
|
145
|
+
case inSource && !inMirror:
|
|
146
|
+
diffs = append(diffs, fmt.Sprintf("%s.%s: dropped by the mirror (%s)", path, key, brief(bv)))
|
|
147
|
+
case !inSource && inMirror:
|
|
148
|
+
diffs = append(diffs, fmt.Sprintf("%s.%s: invented by the mirror (%s)", path, key, brief(av)))
|
|
149
|
+
default:
|
|
150
|
+
diffs = append(diffs, compare(path+"."+key, bv, av)...)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return diffs
|
|
155
|
+
case []any:
|
|
156
|
+
a, ok := after.([]any)
|
|
157
|
+
if !ok {
|
|
158
|
+
return []string{fmt.Sprintf("%s: list in the source, %T after the round trip", path, after)}
|
|
159
|
+
}
|
|
160
|
+
if len(b) != len(a) {
|
|
161
|
+
return []string{fmt.Sprintf("%s: %d entries in the source, %d after the round trip", path, len(b), len(a))}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
var diffs []string
|
|
165
|
+
for i := range b {
|
|
166
|
+
diffs = append(diffs, compare(fmt.Sprintf("%s[%d]", path, i), b[i], a[i])...)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return diffs
|
|
170
|
+
default:
|
|
171
|
+
if before != after {
|
|
172
|
+
return []string{fmt.Sprintf("%s: %s became %s", path, brief(before), brief(after))}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return nil
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func sortedKeys(maps ...map[string]any) []string {
|
|
180
|
+
seen := map[string]bool{}
|
|
181
|
+
for _, m := range maps {
|
|
182
|
+
for k := range m {
|
|
183
|
+
seen[k] = true
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
keys := make([]string, 0, len(seen))
|
|
188
|
+
for k := range seen {
|
|
189
|
+
keys = append(keys, k)
|
|
190
|
+
}
|
|
191
|
+
sort.Strings(keys)
|
|
192
|
+
|
|
193
|
+
return keys
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
func brief(v any) string {
|
|
197
|
+
s := fmt.Sprintf("%v", v)
|
|
198
|
+
if len(s) > 60 {
|
|
199
|
+
return s[:57] + "..."
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return s
|
|
203
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
package catalog
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// TestEdgeViaRoundTrip pins the one field no committed catalog carries: `via`
|
|
9
|
+
// is written by the host after the merge, so the roundtrip over data/*.json
|
|
10
|
+
// never sees it, and the mirror would drop it silently on the way to a
|
|
11
|
+
// generator.
|
|
12
|
+
func TestEdgeViaRoundTrip(t *testing.T) {
|
|
13
|
+
in := `{"service":"payments.ledger","status":"declared","via":{"flow":"order-accepted","step":"a3"}}`
|
|
14
|
+
|
|
15
|
+
var c EventConsumer
|
|
16
|
+
if err := json.Unmarshal([]byte(in), &c); err != nil {
|
|
17
|
+
t.Fatal(err)
|
|
18
|
+
}
|
|
19
|
+
if c.Via == nil || c.Via.Flow != "order-accepted" || c.Via.Step != "a3" {
|
|
20
|
+
t.Fatalf("via not read: %+v", c.Via)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
out, err := json.Marshal(&c)
|
|
24
|
+
if err != nil {
|
|
25
|
+
t.Fatal(err)
|
|
26
|
+
}
|
|
27
|
+
if string(out) != in {
|
|
28
|
+
t.Fatalf("round trip changed the consumer:\n got %s\nwant %s", out, in)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var call RpcCall
|
|
32
|
+
if err := json.Unmarshal([]byte(`{"id":"x/Y","peer":"p","status":"declared","source":"s"}`), &call); err != nil {
|
|
33
|
+
t.Fatal(err)
|
|
34
|
+
}
|
|
35
|
+
if call.Via != nil {
|
|
36
|
+
t.Fatalf("a call without via must round-trip without one: %+v", call.Via)
|
|
37
|
+
}
|
|
38
|
+
}
|
package/cli/init.test.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
6
6
|
|
|
7
|
-
import { InitError, defaultAnswers, init, nextStep } from "./init.mjs";
|
|
7
|
+
import { InitError, defaultAnswers, init, nextStep, toolchainFor } from "./init.mjs";
|
|
8
8
|
|
|
9
9
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
10
|
const created = [];
|
|
@@ -36,6 +36,11 @@ const goService = {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
describe("init with defaults", () => {
|
|
39
|
+
it("declares Go only for the typed HTTP client analyzer", () => {
|
|
40
|
+
expect(toolchainFor("http-clients")).toMatchObject({ command: "go", label: "Go", missing: false });
|
|
41
|
+
expect(toolchainFor("go-domain")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
39
44
|
it("prints runnable next commands with and without package scripts", () => {
|
|
40
45
|
expect(nextStep(false, true)).toBe("Next: npm run architecture:gen && npm run architecture");
|
|
41
46
|
expect(nextStep(true, true)).toBe("Generating; then: npm run architecture");
|
package/cli/portolan.mjs
CHANGED
|
@@ -264,6 +264,14 @@ export async function prepareSite(workspace) {
|
|
|
264
264
|
};
|
|
265
265
|
writeFileSync(resolve(stage, "portolan.json"), `${JSON.stringify(stagedManifest, null, 2)}\n`);
|
|
266
266
|
|
|
267
|
+
// The browser imports flattened paths, but provenance belongs to the
|
|
268
|
+
// original files in the workspace's history. Keep that correspondence
|
|
269
|
+
// outside the fragments so staging never invents a new source stamp.
|
|
270
|
+
mkdirSync(resolve(stage, ".portolan"), { recursive: true });
|
|
271
|
+
writeFileSync(resolve(stage, ".portolan/source-paths.json"), `${JSON.stringify(Object.fromEntries(
|
|
272
|
+
[...flattened].map(([source, staged]) => [staged, source]),
|
|
273
|
+
), null, 2)}\n`);
|
|
274
|
+
|
|
267
275
|
const modules = dependencyRoot();
|
|
268
276
|
symlinkSync(modules, resolve(stage, "node_modules"), process.platform === "win32" ? "junction" : "dir");
|
|
269
277
|
return stage;
|
package/cli/portolan.test.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
5
6
|
|
|
6
7
|
import { prepareSite, VERSION } from "./portolan.mjs";
|
|
8
|
+
import { provenancePlugin, PROVENANCE_MODULE } from "../scripts/provenance.mjs";
|
|
9
|
+
import { mergeCatalogs } from "../src/merge.ts";
|
|
10
|
+
import { validateCatalog } from "../src/catalog.ts";
|
|
7
11
|
|
|
8
12
|
const roots = [];
|
|
9
13
|
afterEach(() => {
|
|
@@ -11,6 +15,51 @@ afterEach(() => {
|
|
|
11
15
|
});
|
|
12
16
|
|
|
13
17
|
describe("site staging", () => {
|
|
18
|
+
it("keeps original Git provenance for flattened sources in every catalog profile", async () => {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), "portolan-staged-provenance-"));
|
|
20
|
+
roots.push(root);
|
|
21
|
+
const fragment = (id) => JSON.stringify({ contexts: [{ id, slug: id, name: id, services: [] }], defs: {}, flows: [], adrs: [] });
|
|
22
|
+
mkdirSync(join(root, "data"));
|
|
23
|
+
mkdirSync(join(root, "examples/shop/portolan"), { recursive: true });
|
|
24
|
+
writeFileSync(join(root, "data/self.json"), fragment("portolan"));
|
|
25
|
+
writeFileSync(join(root, "examples/shop/portolan/domain.json"), fragment("shop"));
|
|
26
|
+
// The example is profile-only: provenance must cover every staged source.
|
|
27
|
+
writeFileSync(join(root, "portolan.json"), JSON.stringify({
|
|
28
|
+
sources: ["data/*.json"],
|
|
29
|
+
catalogs: [
|
|
30
|
+
{ id: "portolan", title: "Portolan", contexts: ["portolan"], projects: [], sources: ["data/*.json"] },
|
|
31
|
+
{ id: "example", title: "Example", contexts: ["shop"], projects: [], sources: ["examples/*/portolan/*.json"] },
|
|
32
|
+
],
|
|
33
|
+
}));
|
|
34
|
+
const git = (...args) => execFileSync("git", ["-C", root, ...args], {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
env: { ...process.env, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com", GIT_AUTHOR_DATE: "2026-03-03T03:00:00Z", GIT_COMMITTER_DATE: "2026-03-03T03:00:00Z" },
|
|
37
|
+
}).trim();
|
|
38
|
+
git("init", "-q");
|
|
39
|
+
git("add", ".");
|
|
40
|
+
git("-c", "commit.gpgsign=false", "commit", "-qm", "catalogs");
|
|
41
|
+
const commit = git("rev-parse", "--short=7", "HEAD");
|
|
42
|
+
|
|
43
|
+
const stage = await prepareSite(root);
|
|
44
|
+
const plugin = provenancePlugin(root);
|
|
45
|
+
plugin.configResolved({ root: stage });
|
|
46
|
+
const module = plugin.load(plugin.resolveId(PROVENANCE_MODULE));
|
|
47
|
+
const stamps = JSON.parse(module.slice("export default ".length).trim().replace(/;$/, ""));
|
|
48
|
+
const manifest = JSON.parse(readFileSync(join(stage, "portolan.json"), "utf8"));
|
|
49
|
+
for (const [i, profile] of manifest.catalogs.entries()) {
|
|
50
|
+
const sources = profile.sources.map((path) => ({
|
|
51
|
+
path, catalog: JSON.parse(readFileSync(join(stage, path), "utf8")), stamp: stamps[path],
|
|
52
|
+
}));
|
|
53
|
+
expect(sources).toHaveLength(1);
|
|
54
|
+
expect(sources[0].catalog.generatedAt).toBeUndefined();
|
|
55
|
+
// The stamp is the workspace file's, and it says which file: the path a
|
|
56
|
+
// project's root is a prefix of, which the flattened name is not.
|
|
57
|
+
expect(sources[0].stamp).toEqual({ commit, generatedAt: "2026-03-03T03:00:00Z", source: i === 0 ? "data/self.json" : "examples/shop/portolan/domain.json" });
|
|
58
|
+
const catalog = validateCatalog(mergeCatalogs(sources).catalog);
|
|
59
|
+
expect(catalog.contexts.map((context) => context.id)).toEqual([i === 0 ? "portolan" : "shop"]);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
14
63
|
it("stages a deterministic source for an intentional empty workspace", async () => {
|
|
15
64
|
const root = mkdtempSync(join(tmpdir(), "portolan-empty-site-"));
|
|
16
65
|
roots.push(root);
|
package/go.mod
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module github.com/shortlink-org/portolan
|
|
2
|
+
|
|
3
|
+
go 1.27.0
|
|
4
|
+
|
|
5
|
+
require (
|
|
6
|
+
github.com/pgplex/pgparser v0.2.0
|
|
7
|
+
golang.org/x/tools v0.50.0
|
|
8
|
+
gopkg.in/yaml.v3 v3.0.1
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
require (
|
|
12
|
+
golang.org/x/mod v0.41.0 // indirect
|
|
13
|
+
golang.org/x/sync v0.23.0 // indirect
|
|
14
|
+
)
|
package/go.sum
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
2
|
+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
3
|
+
github.com/pgplex/pgparser v0.2.0 h1:BB7I/Mrwetiw07PTq/AlXcKfajlCwoJfwB7KZ7V9irI=
|
|
4
|
+
github.com/pgplex/pgparser v0.2.0/go.mod h1:DWhK7oZJn/16KTFeOQ+mk78+sRnEYya1XPghIoP9NFU=
|
|
5
|
+
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
|
6
|
+
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
|
|
7
|
+
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
|
8
|
+
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
|
9
|
+
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
|
10
|
+
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
11
|
+
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
|
12
|
+
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
|
13
|
+
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
|
14
|
+
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
|
15
|
+
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
|
16
|
+
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
|
17
|
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
18
|
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
19
|
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
20
|
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Shared typed Go call facts
|
|
2
|
+
|
|
3
|
+
`Analyze(ctx, Options)` loads a project's Go packages and returns source-backed
|
|
4
|
+
call edges without depending on HTTP or the catalog schema. Positions are
|
|
5
|
+
relative to the input root and include columns. Functions retain their lexical
|
|
6
|
+
parents so each consumer can decide how to display closures.
|
|
7
|
+
|
|
8
|
+
Edges distinguish direct static calls from possible targets computed by VTA.
|
|
9
|
+
Possible targets must not be treated as proof that every implementation runs.
|
|
10
|
+
Consumers should retain ambiguity when assembly cannot narrow the target.
|
|
11
|
+
Package errors produce a partial result with diagnostics when independent
|
|
12
|
+
packages remain usable. If none can be analyzed, the caller receives an error.
|
|
13
|
+
|
|
14
|
+
The first consumer is `internal/gohttp`: its existing native sidecar adapts
|
|
15
|
+
these facts to its syntax index. Other extractors can adopt this API without
|
|
16
|
+
copying the package loader or depending on the HTTP scanner. The syntax-only
|
|
17
|
+
WASI extractors remain toolchain-free; they must not import this native loader.
|
|
18
|
+
Native callers must bound process execution because SSA/VTA cannot be interrupted
|
|
19
|
+
by a context while computing. The context does bound package loading.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Package gocall supplies source-backed, typed call facts to native Go
|
|
2
|
+
// extractors. It has no knowledge of HTTP, domain layouts or catalog lanes.
|
|
3
|
+
package gocall
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"fmt"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"sort"
|
|
10
|
+
"strings"
|
|
11
|
+
|
|
12
|
+
"golang.org/x/tools/go/callgraph"
|
|
13
|
+
"golang.org/x/tools/go/callgraph/vta"
|
|
14
|
+
"golang.org/x/tools/go/packages"
|
|
15
|
+
"golang.org/x/tools/go/ssa"
|
|
16
|
+
"golang.org/x/tools/go/ssa/ssautil"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Position is relative to the analysis root. Column distinguishes call sites
|
|
20
|
+
// on the same line. Empty File means a declaration outside the source root.
|
|
21
|
+
type Position struct {
|
|
22
|
+
File string
|
|
23
|
+
Line int
|
|
24
|
+
Column int
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Definition struct {
|
|
28
|
+
Name string
|
|
29
|
+
Position Position
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Function retains lexical parents so a consumer may map a closure to its
|
|
33
|
+
// enclosing declaration without making that policy part of the shared graph.
|
|
34
|
+
type Function struct {
|
|
35
|
+
ID string
|
|
36
|
+
Definitions []Definition
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Kind string
|
|
40
|
+
|
|
41
|
+
const (
|
|
42
|
+
Static Kind = "static"
|
|
43
|
+
Possible Kind = "possible"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// Edge records a static target or a VTA candidate. A Possible edge is not
|
|
47
|
+
// evidence that the target executes in a particular runtime scenario.
|
|
48
|
+
type Edge struct {
|
|
49
|
+
Caller Function
|
|
50
|
+
Callee Function
|
|
51
|
+
Site Position
|
|
52
|
+
Kind Kind
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Diagnostic struct {
|
|
56
|
+
Package string
|
|
57
|
+
Message string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func (d Diagnostic) String() string { return d.Package + ": " + d.Message }
|
|
61
|
+
|
|
62
|
+
type Result struct {
|
|
63
|
+
Edges []Edge
|
|
64
|
+
Diagnostics []Diagnostic
|
|
65
|
+
// Complete is false when package errors forced a partial graph.
|
|
66
|
+
Complete bool
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Options struct {
|
|
70
|
+
Root string
|
|
71
|
+
Patterns []string
|
|
72
|
+
BuildFlags []string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Analyze respects the caller's deadline while loading packages. Native
|
|
76
|
+
// consumers also bound the whole process, since SSA/VTA do not accept a context.
|
|
77
|
+
// Broken packages and their dependents are excluded; independent packages
|
|
78
|
+
// still contribute facts, accompanied by diagnostics.
|
|
79
|
+
func Analyze(ctx context.Context, opts Options) (Result, error) {
|
|
80
|
+
root, err := filepath.Abs(opts.Root)
|
|
81
|
+
if err != nil {
|
|
82
|
+
return Result{}, err
|
|
83
|
+
}
|
|
84
|
+
patterns := opts.Patterns
|
|
85
|
+
if len(patterns) == 0 {
|
|
86
|
+
patterns = []string{"./..."}
|
|
87
|
+
}
|
|
88
|
+
flags := append([]string{"-mod=readonly"}, opts.BuildFlags...)
|
|
89
|
+
loaded, err := packages.Load(&packages.Config{
|
|
90
|
+
Context: ctx, Dir: root, Mode: packages.LoadSyntax, Tests: false, BuildFlags: flags,
|
|
91
|
+
}, patterns...)
|
|
92
|
+
if err != nil {
|
|
93
|
+
return Result{}, err
|
|
94
|
+
}
|
|
95
|
+
if err := ctx.Err(); err != nil {
|
|
96
|
+
return Result{}, err
|
|
97
|
+
}
|
|
98
|
+
result := Result{Complete: true}
|
|
99
|
+
var usable []*packages.Package
|
|
100
|
+
for _, pkg := range loaded {
|
|
101
|
+
if len(pkg.Errors) > 0 || pkg.IllTyped {
|
|
102
|
+
result.Complete = false
|
|
103
|
+
if len(pkg.Errors) == 0 {
|
|
104
|
+
result.Diagnostics = append(result.Diagnostics, Diagnostic{pkg.PkgPath, "package is ill-typed"})
|
|
105
|
+
}
|
|
106
|
+
for _, problem := range pkg.Errors {
|
|
107
|
+
result.Diagnostics = append(result.Diagnostics, Diagnostic{pkg.PkgPath, problem.Error()})
|
|
108
|
+
}
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
usable = append(usable, pkg)
|
|
112
|
+
}
|
|
113
|
+
sort.Slice(result.Diagnostics, func(i, j int) bool { return result.Diagnostics[i].String() < result.Diagnostics[j].String() })
|
|
114
|
+
if len(usable) == 0 {
|
|
115
|
+
if len(result.Diagnostics) > 0 {
|
|
116
|
+
return result, fmt.Errorf("no usable Go packages: %s", result.Diagnostics[0].String())
|
|
117
|
+
}
|
|
118
|
+
return result, fmt.Errorf("no Go packages found")
|
|
119
|
+
}
|
|
120
|
+
program, _ := ssautil.Packages(usable, ssa.InstantiateGenerics)
|
|
121
|
+
program.Build()
|
|
122
|
+
if err := ctx.Err(); err != nil {
|
|
123
|
+
return result, err
|
|
124
|
+
}
|
|
125
|
+
functions := ssautil.AllFunctions(program)
|
|
126
|
+
if len(functions) == 0 {
|
|
127
|
+
return result, fmt.Errorf("SSA program contains no source functions")
|
|
128
|
+
}
|
|
129
|
+
position := func(filename string, line, column int) Position {
|
|
130
|
+
rel, err := filepath.Rel(root, filename)
|
|
131
|
+
if filename == "" || line == 0 || err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
132
|
+
return Position{}
|
|
133
|
+
}
|
|
134
|
+
return Position{filepath.ToSlash(rel), line, column}
|
|
135
|
+
}
|
|
136
|
+
function := func(fn *ssa.Function) Function {
|
|
137
|
+
out := Function{ID: fn.String()}
|
|
138
|
+
for candidate := fn; candidate != nil; candidate = candidate.Parent() {
|
|
139
|
+
pos := program.Fset.Position(candidate.Pos())
|
|
140
|
+
if at := position(pos.Filename, pos.Line, pos.Column); at.File != "" {
|
|
141
|
+
out.Definitions = append(out.Definitions, Definition{candidate.Name(), at})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
graph := vta.CallGraph(functions, nil)
|
|
147
|
+
seen := map[string]bool{}
|
|
148
|
+
err = callgraph.GraphVisitEdges(graph, func(edge *callgraph.Edge) error {
|
|
149
|
+
if err := ctx.Err(); err != nil {
|
|
150
|
+
return err
|
|
151
|
+
}
|
|
152
|
+
caller, callee := function(edge.Caller.Func), function(edge.Callee.Func)
|
|
153
|
+
if len(caller.Definitions) == 0 || len(callee.Definitions) == 0 {
|
|
154
|
+
return nil
|
|
155
|
+
}
|
|
156
|
+
pos := program.Fset.Position(edge.Pos())
|
|
157
|
+
site := position(pos.Filename, pos.Line, pos.Column)
|
|
158
|
+
kind := Possible
|
|
159
|
+
if edge.Site != nil && edge.Site.Common().StaticCallee() != nil {
|
|
160
|
+
kind = Static
|
|
161
|
+
}
|
|
162
|
+
key := fmt.Sprintf("%s\x00%d:%d\x00%s\x00%s", site.File, site.Line, site.Column, caller.ID, callee.ID)
|
|
163
|
+
if !seen[key] {
|
|
164
|
+
seen[key] = true
|
|
165
|
+
result.Edges = append(result.Edges, Edge{caller, callee, site, kind})
|
|
166
|
+
}
|
|
167
|
+
return nil
|
|
168
|
+
})
|
|
169
|
+
if err != nil {
|
|
170
|
+
return result, err
|
|
171
|
+
}
|
|
172
|
+
sort.Slice(result.Edges, func(i, j int) bool {
|
|
173
|
+
a, b := result.Edges[i], result.Edges[j]
|
|
174
|
+
if a.Site.File != b.Site.File {
|
|
175
|
+
return a.Site.File < b.Site.File
|
|
176
|
+
}
|
|
177
|
+
if a.Site.Line != b.Site.Line {
|
|
178
|
+
return a.Site.Line < b.Site.Line
|
|
179
|
+
}
|
|
180
|
+
if a.Site.Column != b.Site.Column {
|
|
181
|
+
return a.Site.Column < b.Site.Column
|
|
182
|
+
}
|
|
183
|
+
if a.Caller.ID != b.Caller.ID {
|
|
184
|
+
return a.Caller.ID < b.Caller.ID
|
|
185
|
+
}
|
|
186
|
+
return a.Callee.ID < b.Callee.ID
|
|
187
|
+
})
|
|
188
|
+
return result, nil
|
|
189
|
+
}
|