@shortlink-org/portolan 0.2.4 → 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.
Files changed (151) hide show
  1. package/README.md +23 -0
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1066 -0
  5. package/catalog/roundtrip_test.go +203 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +8 -0
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +14 -0
  11. package/go.sum +20 -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 +22 -10
  35. package/plugin/describe.go +118 -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 +109 -46
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-celery/extract.py +0 -2
  42. package/plugins/extract-celery/extract_test.py +1 -1
  43. package/plugins/extract-django/README.md +39 -17
  44. package/plugins/extract-django/domain.py +28 -17
  45. package/plugins/extract-django/extract.py +21 -7
  46. package/plugins/extract-django/extract_test.py +55 -2
  47. package/plugins/extract-django/lifecycle.py +2 -0
  48. package/plugins/extract-django/operations.py +1 -1
  49. package/plugins/extract-django/routing_test.py +109 -1
  50. package/plugins/extract-django/store.py +1 -1
  51. package/plugins/extract-django/transport.py +101 -55
  52. package/plugins/extract-django/verbs.py +241 -0
  53. package/plugins/extract-go/README.md +47 -0
  54. package/plugins/extract-http-clients/describe.go +19 -0
  55. package/plugins/extract-http-clients/describe_test.go +11 -0
  56. package/plugins/extract-http-clients/extract.go +740 -0
  57. package/plugins/extract-http-clients/extract_test.go +1561 -0
  58. package/plugins/extract-http-clients/main.go +41 -0
  59. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  60. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  61. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  62. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  63. package/plugins/extract-python-kafka/extract.py +0 -2
  64. package/plugins/extract-python-kafka/extract_test.py +1 -1
  65. package/plugins/extract-ts/extract.test.ts +2 -2
  66. package/plugins/extract-ts/extract.ts +4 -5
  67. package/plugins/extract-ts/graphql.test.ts +1 -1
  68. package/plugins/openapi/ids.go +261 -0
  69. package/plugins/openapi/ids_test.go +98 -0
  70. package/plugins/portolan-go.wasm +0 -0
  71. package/plugins/pyplugin/protocol.py +1 -5
  72. package/portolan.json +3 -2
  73. package/schema/portolan.schema.json +34 -0
  74. package/scripts/README.md +18 -12
  75. package/scripts/catalog-sources.mjs +6 -0
  76. package/scripts/delivery-presets.mjs +21 -11
  77. package/scripts/diff.mjs +5 -1
  78. package/scripts/django-aggregates.test.mjs +58 -0
  79. package/scripts/gen-likec4.mjs +1 -1
  80. package/scripts/gen.mjs +118 -115
  81. package/scripts/go-discovery.test.mjs +30 -0
  82. package/scripts/history.mjs +186 -3
  83. package/scripts/history.test.mjs +1 -1
  84. package/scripts/host-plugins/fetch-git.mjs +77 -21
  85. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  86. package/scripts/local-api.mjs +71 -4
  87. package/scripts/local-api.test.mjs +63 -4
  88. package/scripts/local-discovery.mjs +82 -9
  89. package/scripts/manifest.mjs +5 -3
  90. package/scripts/manifest.test.mjs +24 -0
  91. package/scripts/output-diff.mjs +94 -0
  92. package/scripts/output-diff.test.mjs +36 -0
  93. package/scripts/package-smoke.mjs +62 -4
  94. package/scripts/plugin-host.mjs +22 -2
  95. package/scripts/plugin-host.test.mjs +9 -0
  96. package/scripts/plugin-wasm-worker.mjs +4 -1
  97. package/scripts/provenance.mjs +72 -0
  98. package/scripts/provenance.test.mjs +149 -0
  99. package/scripts/run-builtin.mjs +39 -5
  100. package/scripts/schema.mjs +29 -0
  101. package/scripts/warning-policy.mjs +161 -0
  102. package/scripts/warning-policy.test.mjs +56 -0
  103. package/src/app/Sidebar.tsx +3 -3
  104. package/src/catalog-docs.test.ts +64 -0
  105. package/src/catalog-docs.ts +35 -0
  106. package/src/catalog-error.test.ts +15 -0
  107. package/src/catalog-model.ts +48 -5
  108. package/src/catalog-validation.ts +9 -0
  109. package/src/chat/Starter.tsx +5 -11
  110. package/src/chat/tools.test.ts +27 -0
  111. package/src/chat/tools.ts +5 -9
  112. package/src/components/CatalogStamp.tsx +10 -8
  113. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  114. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  115. package/src/components/Integrations.tsx +1 -1
  116. package/src/components/MachineDocs.tsx +6 -5
  117. package/src/components/MethodRows.tsx +9 -2
  118. package/src/components/RelationEvidence.test.tsx +14 -0
  119. package/src/components/RelationEvidence.tsx +53 -0
  120. package/src/data.ts +25 -7
  121. package/src/enrich.test.ts +332 -1
  122. package/src/enrich.ts +206 -3
  123. package/src/flow/StepDetail.tsx +6 -0
  124. package/src/flow/evidence.test.ts +16 -0
  125. package/src/flow/evidence.ts +34 -0
  126. package/src/index.css +44 -0
  127. package/src/landing/DraggableReveal.tsx +3 -2
  128. package/src/landing/EvidencePipeline.tsx +105 -0
  129. package/src/landing/LandingPage.tsx +2 -59
  130. package/src/lib/catalog-diff.ts +1 -1
  131. package/src/lib/django-aggregates.d.mts +9 -0
  132. package/src/lib/django-aggregates.mjs +36 -0
  133. package/src/lib/django-aggregates.test.ts +29 -0
  134. package/src/lib/django-aggregates.ts +5 -0
  135. package/src/lib/local-api.ts +20 -2
  136. package/src/lib/setup-info.test.ts +17 -0
  137. package/src/lib/setup-info.ts +58 -0
  138. package/src/lib/warnings.test.ts +54 -0
  139. package/src/lib/warnings.ts +260 -0
  140. package/src/map/ContextMapGraph.tsx +76 -32
  141. package/src/merge.ts +16 -9
  142. package/src/pages/AggregatePage.tsx +8 -7
  143. package/src/pages/ContextPage.tsx +6 -5
  144. package/src/pages/ServicePage.tsx +4 -3
  145. package/src/pages/Settings.tsx +189 -41
  146. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  147. package/src/selection/DetailPanel.tsx +15 -0
  148. package/src/virtual-provenance.d.ts +11 -0
  149. package/vite.config.ts +5 -0
  150. package/scripts/vendor-lock.mjs +0 -58
  151. 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;
@@ -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
+ }