@ctxo/lang-go 0.8.0-alpha.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctxo/lang-go",
3
- "version": "0.8.0-alpha.0",
3
+ "version": "0.8.0",
4
4
  "description": "Ctxo Go language plugin (ctxo-go-analyzer + tree-sitter, full tier)",
5
5
  "type": "module",
6
6
  "engines": {
@@ -37,14 +37,14 @@
37
37
  "tree-sitter-go": "^0.23.4"
38
38
  },
39
39
  "peerDependencies": {
40
- "@ctxo/plugin-api": "^0.7.0"
40
+ "@ctxo/plugin-api": "^0.7.1"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^22.15.3",
44
44
  "tsup": "^8.4.0",
45
45
  "typescript": "^5.8.3",
46
46
  "vitest": "^3.1.3",
47
- "@ctxo/plugin-api": "0.7.0"
47
+ "@ctxo/plugin-api": "0.7.1"
48
48
  },
49
49
  "repository": {
50
50
  "type": "git",
@@ -55,6 +55,10 @@
55
55
  "url": "https://github.com/alperhankendi/Ctxo/issues"
56
56
  },
57
57
  "homepage": "https://github.com/alperhankendi/Ctxo#readme",
58
+ "publishConfig": {
59
+ "access": "public",
60
+ "provenance": true
61
+ },
58
62
  "scripts": {
59
63
  "build": "tsup",
60
64
  "typecheck": "tsc --noEmit",
@@ -81,9 +81,9 @@ func main() { _ = ID[int](1) }
81
81
  `,
82
82
  })
83
83
 
84
- res, err := load.Packages(dir)
85
- if err != nil {
86
- t.Fatal(err)
84
+ res := load.Packages(dir)
85
+ if res.FatalError != nil {
86
+ t.Fatal(res.FatalError)
87
87
  }
88
88
  ext := NewExtractor(dir, res.Packages)
89
89
 
@@ -127,9 +127,9 @@ func main() { _ = strings.ToUpper("hi") }
127
127
 
128
128
  func extractAllEdges(t *testing.T, dir string) map[string]bool {
129
129
  t.Helper()
130
- res, err := load.Packages(dir)
131
- if err != nil {
132
- t.Fatal(err)
130
+ res := load.Packages(dir)
131
+ if res.FatalError != nil {
132
+ t.Fatal(res.FatalError)
133
133
  }
134
134
  ext := NewExtractor(dir, res.Packages)
135
135
  out := map[string]bool{}
@@ -103,9 +103,9 @@ type Guarded struct {
103
103
 
104
104
  func extractAll(t *testing.T, dir string) map[string]bool {
105
105
  t.Helper()
106
- res, err := load.Packages(dir)
107
- if err != nil {
108
- t.Fatal(err)
106
+ res := load.Packages(dir)
107
+ if res.FatalError != nil {
108
+ t.Fatal(res.FatalError)
109
109
  }
110
110
  out := map[string]bool{}
111
111
  for _, list := range Extract(dir, res.Packages) {
@@ -39,9 +39,9 @@ type LoudGreeter interface {
39
39
  `,
40
40
  })
41
41
 
42
- res, err := load.Packages(dir)
43
- if err != nil {
44
- t.Fatal(err)
42
+ res := load.Packages(dir)
43
+ if res.FatalError != nil {
44
+ t.Fatal(res.FatalError)
45
45
  }
46
46
 
47
47
  out := Extract(dir, res.Packages)
@@ -93,9 +93,9 @@ func (i ID) String() string { return string(i) }
93
93
  `,
94
94
  })
95
95
 
96
- res, err := load.Packages(dir)
97
- if err != nil {
98
- t.Fatal(err)
96
+ res := load.Packages(dir)
97
+ if res.FatalError != nil {
98
+ t.Fatal(res.FatalError)
99
99
  }
100
100
  out := Extract(dir, res.Packages)
101
101
 
@@ -5,7 +5,10 @@ package load
5
5
 
6
6
  import (
7
7
  "fmt"
8
+ "io/fs"
8
9
  "os"
10
+ "path/filepath"
11
+ "strings"
9
12
 
10
13
  "golang.org/x/tools/go/packages"
11
14
  )
@@ -24,18 +27,39 @@ const Mode = packages.NeedName |
24
27
  packages.NeedTypesSizes |
25
28
  packages.NeedModule
26
29
 
30
+ // skippedDirs are filesystem entries we never descend into during subdir
31
+ // fallback. vendor and node_modules avoid double-loading deps; hidden
32
+ // directories tend to be tooling state, not source.
33
+ var skippedDirs = map[string]bool{
34
+ "vendor": true,
35
+ ".git": true,
36
+ ".ctxo": true,
37
+ "node_modules": true,
38
+ "testdata": true,
39
+ }
40
+
27
41
  // LoadResult bundles the loaded package set with any non-fatal errors so the
28
42
  // caller can decide whether to surface them as warnings.
29
43
  type LoadResult struct {
30
44
  Packages []*packages.Package
31
- // Errors are package-level Errors collected from packages.PrintErrors-style
32
- // traversal. Returned for visibility — the loader does not fail the run.
45
+ // Errors are package-level Errors collected from a Visit traversal.
46
+ // Returned for visibility — the loader does not fail the run on these.
33
47
  Errors []packages.Error
48
+ // FatalError is set when packages.Load returned a top-level error (e.g.,
49
+ // module-graph conflict). The caller may still get partial Packages.
50
+ FatalError error
51
+ // FallbackUsed is true when the root pattern failed and per-subdir
52
+ // recovery was used to gather whatever packages still loaded cleanly.
53
+ FallbackUsed bool
34
54
  }
35
55
 
36
56
  // Packages loads "./..." rooted at dir. Patterns can override the default
37
57
  // for tests that want to scope analysis to a single sub-package.
38
- func Packages(dir string, patterns ...string) (*LoadResult, error) {
58
+ //
59
+ // Failure mode: returns a LoadResult (possibly with empty Packages) and
60
+ // sets FatalError. The caller should surface the error but continue —
61
+ // degraded analysis beats no analysis when consumers have a flaky module.
62
+ func Packages(dir string, patterns ...string) *LoadResult {
39
63
  if len(patterns) == 0 {
40
64
  patterns = []string{"./..."}
41
65
  }
@@ -54,14 +78,85 @@ func Packages(dir string, patterns ...string) (*LoadResult, error) {
54
78
  }
55
79
 
56
80
  pkgs, err := packages.Load(cfg, patterns...)
81
+ res := &LoadResult{Packages: pkgs}
57
82
  if err != nil {
58
- return nil, fmt.Errorf("load: %w", err)
83
+ res.FatalError = fmt.Errorf("load: %w", err)
59
84
  }
60
85
 
61
- var errs []packages.Error
62
86
  packages.Visit(pkgs, nil, func(p *packages.Package) {
63
- errs = append(errs, p.Errors...)
87
+ res.Errors = append(res.Errors, p.Errors...)
64
88
  })
89
+ return res
90
+ }
91
+
92
+ // PackagesWithFallback tries to load the whole module first; if that yields
93
+ // zero packages (typical when the module graph has a fatal conflict), it
94
+ // walks top-level subdirectories and loads each independently. Subdirs that
95
+ // transitively avoid the conflicting imports still produce full type info.
96
+ func PackagesWithFallback(dir string) *LoadResult {
97
+ primary := Packages(dir)
98
+ if len(primary.Packages) > 0 {
99
+ return primary
100
+ }
65
101
 
66
- return &LoadResult{Packages: pkgs, Errors: errs}, nil
102
+ subdirs := topLevelGoSubdirs(dir)
103
+ if len(subdirs) == 0 {
104
+ return primary
105
+ }
106
+
107
+ merged := &LoadResult{
108
+ FatalError: primary.FatalError,
109
+ FallbackUsed: true,
110
+ }
111
+ for _, sub := range subdirs {
112
+ pattern := "./" + filepath.ToSlash(sub) + "/..."
113
+ subRes := Packages(dir, pattern)
114
+ if len(subRes.Packages) == 0 {
115
+ continue
116
+ }
117
+ merged.Packages = append(merged.Packages, subRes.Packages...)
118
+ merged.Errors = append(merged.Errors, subRes.Errors...)
119
+ }
120
+ return merged
121
+ }
122
+
123
+ // topLevelGoSubdirs returns immediate subdirectories of root that contain at
124
+ // least one .go file (recursively). Skips vendor, .git, hidden dirs, etc.
125
+ func topLevelGoSubdirs(root string) []string {
126
+ entries, err := os.ReadDir(root)
127
+ if err != nil {
128
+ return nil
129
+ }
130
+ var out []string
131
+ for _, e := range entries {
132
+ if !e.IsDir() {
133
+ continue
134
+ }
135
+ name := e.Name()
136
+ if skippedDirs[name] || strings.HasPrefix(name, ".") {
137
+ continue
138
+ }
139
+ if hasGoFile(filepath.Join(root, name)) {
140
+ out = append(out, name)
141
+ }
142
+ }
143
+ return out
144
+ }
145
+
146
+ func hasGoFile(dir string) bool {
147
+ found := false
148
+ _ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
149
+ if err != nil {
150
+ return nil
151
+ }
152
+ if d.IsDir() && skippedDirs[d.Name()] {
153
+ return fs.SkipDir
154
+ }
155
+ if !d.IsDir() && strings.HasSuffix(d.Name(), ".go") {
156
+ found = true
157
+ return fs.SkipAll
158
+ }
159
+ return nil
160
+ })
161
+ return found
67
162
  }
@@ -0,0 +1,96 @@
1
+ package load
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "sort"
7
+ "testing"
8
+
9
+ "golang.org/x/tools/go/packages"
10
+ )
11
+
12
+ func TestPackagesOnHealthyModule(t *testing.T) {
13
+ dir := writeFixture(t, map[string]string{
14
+ "go.mod": "module fixture\n\ngo 1.22\n",
15
+ "a.go": "package fixture\n\nfunc Hello() string { return \"hi\" }\n",
16
+ })
17
+ res := Packages(dir)
18
+ if res.FatalError != nil {
19
+ t.Fatalf("FatalError: %v", res.FatalError)
20
+ }
21
+ if len(res.Packages) == 0 {
22
+ t.Fatal("expected at least one package")
23
+ }
24
+ }
25
+
26
+ func TestPackagesWithFallbackRecoversPartial(t *testing.T) {
27
+ // "broken/" imports a non-existent path so the module-wide load fails;
28
+ // "healthy/" is self-contained, so per-subdir loading recovers it.
29
+ dir := writeFixture(t, map[string]string{
30
+ "go.mod": "module fixture\n\ngo 1.22\n",
31
+ "broken/broken.go": "package broken\n\nimport _ \"definitely.nowhere/missing\"\n",
32
+ "healthy/healthy.go": "package healthy\n\nfunc Y() int { return 1 }\n",
33
+ })
34
+
35
+ res := PackagesWithFallback(dir)
36
+ if len(res.Packages) == 0 {
37
+ t.Fatalf("expected fallback to recover at least one package; got 0 (FatalError=%v)", res.FatalError)
38
+ }
39
+ if !pkgListContainsName(res.Packages, "healthy") {
40
+ t.Errorf("expected 'healthy' package recovered via fallback; got %v", pkgNames(res.Packages))
41
+ }
42
+ }
43
+
44
+ func TestTopLevelGoSubdirsSkipsHidden(t *testing.T) {
45
+ dir := writeFixture(t, map[string]string{
46
+ "a/file.go": "package a",
47
+ ".hidden/x.go": "package x",
48
+ "vendor/foo.go": "package foo",
49
+ "b/file.go": "package b",
50
+ })
51
+ got := topLevelGoSubdirs(dir)
52
+ sort.Strings(got)
53
+ want := []string{"a", "b"}
54
+ if len(got) != len(want) {
55
+ t.Fatalf("got %v, want %v", got, want)
56
+ }
57
+ for i := range got {
58
+ if got[i] != want[i] {
59
+ t.Fatalf("index %d: got %q want %q", i, got[i], want[i])
60
+ }
61
+ }
62
+ }
63
+
64
+ // ── helpers ──
65
+
66
+ func writeFixture(t *testing.T, files map[string]string) string {
67
+ t.Helper()
68
+ dir := t.TempDir()
69
+ for name, body := range files {
70
+ path := filepath.Join(dir, name)
71
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ }
78
+ return dir
79
+ }
80
+
81
+ func pkgNames(pkgs []*packages.Package) []string {
82
+ out := make([]string, 0, len(pkgs))
83
+ for _, p := range pkgs {
84
+ out = append(out, p.Name)
85
+ }
86
+ return out
87
+ }
88
+
89
+ func pkgListContainsName(pkgs []*packages.Package, name string) bool {
90
+ for _, p := range pkgs {
91
+ if p.Name == name {
92
+ return true
93
+ }
94
+ }
95
+ return false
96
+ }
@@ -119,9 +119,9 @@ func main() {
119
119
 
120
120
  func analyze(t *testing.T, dir string) *Result {
121
121
  t.Helper()
122
- res, err := load.Packages(dir)
123
- if err != nil {
124
- t.Fatal(err)
122
+ res := load.Packages(dir)
123
+ if res.FatalError != nil {
124
+ t.Fatal(res.FatalError)
125
125
  }
126
126
  return Analyze(dir, res.Packages, 30*time.Second)
127
127
  }
@@ -47,9 +47,9 @@ func (u User) ID() string { return u.Name }
47
47
  `,
48
48
  })
49
49
 
50
- res, err := load.Packages(dir)
51
- if err != nil {
52
- t.Fatalf("Packages: %v", err)
50
+ res := load.Packages(dir)
51
+ if res.FatalError != nil {
52
+ t.Fatalf("Packages: %v", res.FatalError)
53
53
  }
54
54
  if len(res.Packages) == 0 {
55
55
  t.Fatal("no packages loaded")
@@ -123,9 +123,9 @@ type Box[T any] struct{ v T }
123
123
  func (b *Box[T]) Get() T { return b.v }
124
124
  `,
125
125
  })
126
- res, err := load.Packages(dir)
127
- if err != nil {
128
- t.Fatal(err)
126
+ res := load.Packages(dir)
127
+ if res.FatalError != nil {
128
+ t.Fatal(res.FatalError)
129
129
  }
130
130
 
131
131
  found := false
@@ -66,9 +66,12 @@ func run(root string, stdout *os.File) error {
66
66
  return err
67
67
  }
68
68
 
69
- res, err := load.Packages(root)
70
- if err != nil {
71
- return fmt.Errorf("load packages: %w", err)
69
+ res := load.PackagesWithFallback(root)
70
+ if res.FatalError != nil {
71
+ _ = w.Progress(fmt.Sprintf("module load returned a fatal error (degrading): %v", res.FatalError))
72
+ }
73
+ if res.FallbackUsed {
74
+ _ = w.Progress(fmt.Sprintf("root pattern failed; per-subdir fallback recovered %d packages", len(res.Packages)))
72
75
  }
73
76
  if len(res.Errors) > 0 {
74
77
  _ = w.Progress(fmt.Sprintf("load reported %d package-level errors (continuing)", len(res.Errors)))