@shortlink-org/portolan 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
@@ -0,0 +1,210 @@
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
+ // The snapshot a fetcher wrote is a catalog too, and the one place the
31
+ // deployments field is exercised by a real file.
32
+ snapshots, err := filepath.Glob("../examples/argocd/argocd.apps.json")
33
+ if err != nil {
34
+ t.Fatal(err)
35
+ }
36
+ files = append(files, snapshots...)
37
+
38
+ for _, path := range files {
39
+ t.Run(filepath.Base(path), func(t *testing.T) {
40
+ raw, err := os.ReadFile(path)
41
+ if err != nil {
42
+ t.Fatal(err)
43
+ }
44
+
45
+ var before any
46
+ if err := json.Unmarshal(raw, &before); err != nil {
47
+ t.Fatalf("source is not JSON: %v", err)
48
+ }
49
+
50
+ var parsed Catalog
51
+ if err := json.Unmarshal(raw, &parsed); err != nil {
52
+ t.Fatalf("catalog does not fit the mirror: %v", err)
53
+ }
54
+
55
+ out, err := json.Marshal(&parsed)
56
+ if err != nil {
57
+ t.Fatalf("mirror does not marshal: %v", err)
58
+ }
59
+
60
+ var after any
61
+ if err := json.Unmarshal(out, &after); err != nil {
62
+ t.Fatal(err)
63
+ }
64
+
65
+ diffs := compare("catalog", normalize(before), normalize(after))
66
+ if len(diffs) == 0 {
67
+ return
68
+ }
69
+
70
+ shown := diffs
71
+ if len(shown) > 20 {
72
+ shown = shown[:20]
73
+ }
74
+ for _, d := range shown {
75
+ t.Error(d)
76
+ }
77
+ if len(diffs) > len(shown) {
78
+ t.Errorf("... and %d more", len(diffs)-len(shown))
79
+ }
80
+ })
81
+ }
82
+ }
83
+
84
+ func TestChannelMessageWireFormatRoundTrip(t *testing.T) {
85
+ before := ChannelMessage{
86
+ Name: "cart.BasketCreated", Direction: ChannelSend,
87
+ Encoding: "msgpack", ContentType: "application/msgpack",
88
+ }
89
+ raw, err := json.Marshal(before)
90
+ if err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ var after ChannelMessage
94
+ if err := json.Unmarshal(raw, &after); err != nil {
95
+ t.Fatal(err)
96
+ }
97
+ if after.Encoding != before.Encoding || after.ContentType != before.ContentType {
98
+ t.Fatalf("wire format changed: %+v", after)
99
+ }
100
+ }
101
+
102
+ // normalize drops keys whose value carries no information. An optional list
103
+ // written as [] and one left out entirely say the same thing, and holding the
104
+ // mirror to which of the two it produces would test encoding/json rather than
105
+ // the shape.
106
+ func normalize(v any) any {
107
+ switch t := v.(type) {
108
+ case map[string]any:
109
+ out := make(map[string]any, len(t))
110
+ for k, item := range t {
111
+ switch inner := item.(type) {
112
+ case []any:
113
+ if len(inner) == 0 {
114
+ continue
115
+ }
116
+ case map[string]any:
117
+ if len(inner) == 0 {
118
+ continue
119
+ }
120
+ }
121
+ out[k] = normalize(item)
122
+ }
123
+
124
+ return out
125
+ case []any:
126
+ out := make([]any, len(t))
127
+ for i, item := range t {
128
+ out[i] = normalize(item)
129
+ }
130
+
131
+ return out
132
+ default:
133
+ return v
134
+ }
135
+ }
136
+
137
+ // compare walks both trees together and names every place they part, as a path
138
+ // a reader can find in the file.
139
+ func compare(path string, before, after any) []string {
140
+ switch b := before.(type) {
141
+ case map[string]any:
142
+ a, ok := after.(map[string]any)
143
+ if !ok {
144
+ return []string{fmt.Sprintf("%s: object in the source, %T after the round trip", path, after)}
145
+ }
146
+
147
+ var diffs []string
148
+ for _, key := range sortedKeys(b, a) {
149
+ bv, inSource := b[key]
150
+ av, inMirror := a[key]
151
+ switch {
152
+ case inSource && !inMirror:
153
+ diffs = append(diffs, fmt.Sprintf("%s.%s: dropped by the mirror (%s)", path, key, brief(bv)))
154
+ case !inSource && inMirror:
155
+ diffs = append(diffs, fmt.Sprintf("%s.%s: invented by the mirror (%s)", path, key, brief(av)))
156
+ default:
157
+ diffs = append(diffs, compare(path+"."+key, bv, av)...)
158
+ }
159
+ }
160
+
161
+ return diffs
162
+ case []any:
163
+ a, ok := after.([]any)
164
+ if !ok {
165
+ return []string{fmt.Sprintf("%s: list in the source, %T after the round trip", path, after)}
166
+ }
167
+ if len(b) != len(a) {
168
+ return []string{fmt.Sprintf("%s: %d entries in the source, %d after the round trip", path, len(b), len(a))}
169
+ }
170
+
171
+ var diffs []string
172
+ for i := range b {
173
+ diffs = append(diffs, compare(fmt.Sprintf("%s[%d]", path, i), b[i], a[i])...)
174
+ }
175
+
176
+ return diffs
177
+ default:
178
+ if before != after {
179
+ return []string{fmt.Sprintf("%s: %s became %s", path, brief(before), brief(after))}
180
+ }
181
+
182
+ return nil
183
+ }
184
+ }
185
+
186
+ func sortedKeys(maps ...map[string]any) []string {
187
+ seen := map[string]bool{}
188
+ for _, m := range maps {
189
+ for k := range m {
190
+ seen[k] = true
191
+ }
192
+ }
193
+
194
+ keys := make([]string, 0, len(seen))
195
+ for k := range seen {
196
+ keys = append(keys, k)
197
+ }
198
+ sort.Strings(keys)
199
+
200
+ return keys
201
+ }
202
+
203
+ func brief(v any) string {
204
+ s := fmt.Sprintf("%v", v)
205
+ if len(s) > 60 {
206
+ return s[:57] + "..."
207
+ }
208
+
209
+ return s
210
+ }
@@ -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
@@ -213,7 +213,9 @@ export async function prepareSite(workspace) {
213
213
  rmSync(stage, { recursive: true, force: true });
214
214
  mkdirSync(stage, { recursive: true });
215
215
 
216
- for (const directory of ["src", "public", "scripts"]) {
216
+ // rules/ rides along: scripts/manifest.mjs and src/lib/problem-rules.ts read
217
+ // ../rules/builtin.json beside themselves.
218
+ for (const directory of ["src", "public", "scripts", "rules"]) {
217
219
  cpSync(resolve(installRoot, directory), resolve(stage, directory), { recursive: true });
218
220
  }
219
221
  for (const file of ["index.html", "vite.config.ts", "tsconfig.json"]) {
@@ -264,6 +266,14 @@ export async function prepareSite(workspace) {
264
266
  };
265
267
  writeFileSync(resolve(stage, "portolan.json"), `${JSON.stringify(stagedManifest, null, 2)}\n`);
266
268
 
269
+ // The browser imports flattened paths, but provenance belongs to the
270
+ // original files in the workspace's history. Keep that correspondence
271
+ // outside the fragments so staging never invents a new source stamp.
272
+ mkdirSync(resolve(stage, ".portolan"), { recursive: true });
273
+ writeFileSync(resolve(stage, ".portolan/source-paths.json"), `${JSON.stringify(Object.fromEntries(
274
+ [...flattened].map(([source, staged]) => [staged, source]),
275
+ ), null, 2)}\n`);
276
+
267
277
  const modules = dependencyRoot();
268
278
  symlinkSync(modules, resolve(stage, "node_modules"), process.platform === "win32" ? "junction" : "dir");
269
279
  return stage;
@@ -374,6 +384,9 @@ function prepareHost(workspace) {
374
384
  mkdirSync(host, { recursive: true });
375
385
  cpSync(resolve(installRoot, "scripts"), resolve(host, "scripts"), { recursive: true });
376
386
  cpSync(resolve(installRoot, "src"), resolve(host, "src"), { recursive: true });
387
+ // scripts/manifest.mjs and src/lib/problem-rules.ts read ../rules/builtin.json
388
+ // beside themselves; the host is the package with the same shape.
389
+ cpSync(resolve(installRoot, "rules"), resolve(host, "rules"), { recursive: true });
377
390
  writeFileSync(resolve(host, "package.json"), `${JSON.stringify({ private: true, type: "module", version: VERSION })}\n`);
378
391
  symlinkSync(dependencyRoot(), resolve(host, "node_modules"), process.platform === "win32" ? "junction" : "dir");
379
392
  return host;
@@ -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,21 @@
1
+ module github.com/shortlink-org/portolan
2
+
3
+ go 1.27.0
4
+
5
+ require (
6
+ github.com/hashicorp/hcl/v2 v2.24.0
7
+ github.com/pgplex/pgparser v0.2.0
8
+ github.com/zclconf/go-cty v1.19.0
9
+ golang.org/x/tools v0.50.0
10
+ gopkg.in/yaml.v3 v3.0.1
11
+ )
12
+
13
+ require (
14
+ github.com/agext/levenshtein v1.2.1 // indirect
15
+ github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
16
+ github.com/apparentlymart/go-textseg/v17 v17.0.1 // indirect
17
+ github.com/mitchellh/go-wordwrap v1.0.1 // indirect
18
+ golang.org/x/mod v0.41.0 // indirect
19
+ golang.org/x/sync v0.23.0 // indirect
20
+ golang.org/x/text v0.25.0 // indirect
21
+ )
package/go.sum ADDED
@@ -0,0 +1,34 @@
1
+ github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
2
+ github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
3
+ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
4
+ github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
5
+ github.com/apparentlymart/go-textseg/v17 v17.0.1 h1:bpMXRgQ5cEoRNuQke1a80/Nl6w3G5eoIbWo9f3gXkAs=
6
+ github.com/apparentlymart/go-textseg/v17 v17.0.1/go.mod h1:fa8X4jgGeevslICIY6LcdjkSecWnXmYd9Lk34z/VxZs=
7
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
8
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9
+ github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
10
+ github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
11
+ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
12
+ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
13
+ github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
14
+ github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
15
+ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
16
+ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
17
+ github.com/pgplex/pgparser v0.2.0 h1:BB7I/Mrwetiw07PTq/AlXcKfajlCwoJfwB7KZ7V9irI=
18
+ github.com/pgplex/pgparser v0.2.0/go.mod h1:DWhK7oZJn/16KTFeOQ+mk78+sRnEYya1XPghIoP9NFU=
19
+ github.com/zclconf/go-cty v1.19.0 h1:IV8WdqYZc2c5rLX9bEoLNXKojBAp0MZPBHMIrCoa/s4=
20
+ github.com/zclconf/go-cty v1.19.0/go.mod h1:12W89jGn3JCOIQi7infWr9m80rOkb5RNYJqXMZcN4c8=
21
+ github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
22
+ github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
23
+ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
24
+ golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
25
+ golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
26
+ golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
27
+ golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
28
+ golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
29
+ golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
30
+ golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
31
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
32
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
33
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
34
+ 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
+ }