Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ All notable changes to cymbal are documented here.
- **OpenCode plugins now surface update notices through native OS notifications** — when a newer cymbal version is available, the OpenCode plugin shows a platform-native notification (macOS Notification Center via `osascript`, Linux via `notify-send`, Windows via PowerShell) so users see updates regardless of TUI or Desktop mode. Respects `CYMBAL_NO_UPDATE_NOTIFIER` and cymbal's per-version notification throttle. ([#23](https://github.com/1broseidon/cymbal/issues/23))
- **New `cymbal hook notify` command** — emits a structured JSON payload with update availability, version, and install command for agent plugins that want to surface update notices outside hidden system context. Supports `--format=json|text` and `--update=cache|if-stale`.

### Changed

- **Bumped Go toolchain floor to 1.26.3** — resolves 10 stdlib vulnerabilities reported by `govulncheck`, including callable traces in `net` and `net/http` reached from the update notifier. CI uses `go-version-file: go.mod`; local builds with Go 1.21+ auto-fetch the new toolchain. ([#54](https://github.com/1broseidon/cymbal/pull/54))

### Fixed

- **`cymbal investigate` no longer leaks references across languages** — when a name resolves to a single symbol, refs and transitive impact are filtered to the resolved symbol's language, so investigating a Go `App` struct in a polyglot repo no longer returns call sites from a TSX function with the same name. `cymbal refs` keeps its documented best-effort name-only behavior.
- **`.tsx` files are now parsed with the TSX grammar** — anonymous arrow functions inside JSX props (`onClick={async () => ...}`) were being indexed as phantom `method async` symbols because the plain TypeScript grammar can't see JSX boundaries. `.tsx` is now its own language entry using `tree-sitter-typescript`'s TSX grammar; `.ts`/`.mts`/`.cts` continue to use the TypeScript grammar.
- **Swift field and property accesses now record references** — `cymbal refs <fieldName>` previously returned zero hits because `extractRefSwift` only handled call expressions and named type uses. Member access through `navigation_expression` (e.g. `self.field`, `field.method()`, `self.a.b = x`) now produces refs. `cymbal refs` is still best-effort and name-only.
- **TypeScript `export function` declarations now retain their signature** — exported functions and `export const fn = (x) => …` arrow forms were emitting an empty `signature` field because signature extraction looked for parameters on the outer wrapper node. The extractor now descends through `export_statement` and `lexical_declaration` wrappers before reading parameters and return type.
- **Swift symbol ranges anchor at the keyword, not at preceding attributes** — protocols / classes / functions decorated with `@MainActor`, `@objc`, `public`, etc. previously had their start line absorbed into the leading attributes block, so `cymbal show` and `cymbal outline` reported a range one line above the actual `protocol` / `class` / `func` keyword.

## [0.13.1] - 2026-05-06

### Changed
Expand Down
16 changes: 8 additions & 8 deletions index/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -939,22 +939,22 @@ func Investigate(dbPath, symbolName string, opts ...InvestigateOpts) (*Investiga
switch sym.Kind {
case "function", "method":
res.Kind = "function"
res.Refs, _ = store.FindReferences(sym.Name, 20)
res.Impact, _ = store.FindImpact(sym.Name, 2, 20)
res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20)
res.Impact, _ = store.FindImpactScoped(sym.Name, sym.Language, 2, 20)

case "class", "struct", "type", "interface", "trait", "enum", "object", "mixin", "extension", "protocol", "record", "actor":
res.Kind = "type"
res.Members, _ = store.ChildSymbols(sym.Name, 50, sym.File)
// For types, show who references the type name.
res.Refs, _ = store.FindReferences(sym.Name, 20)
res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20)
// Inheritance / conformance edges (both directions, best-effort).
res.Implementors, _ = store.FindImplementors(sym.Name, 20)
res.Implements, _ = store.FindImplements(sym.Name, 20)

default:
// Unknown kind — return source + refs as best effort.
res.Kind = sym.Kind
res.Refs, _ = store.FindReferences(sym.Name, 20)
res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20)
}

return res, nil
Expand Down Expand Up @@ -993,17 +993,17 @@ func InvestigateResolved(dbPath string, sym SymbolResult) (*InvestigateResult, e
switch sym.Kind {
case "function", "method":
res.Kind = "function"
res.Refs, _ = store.FindReferences(sym.Name, 20)
res.Impact, _ = store.FindImpact(sym.Name, 2, 20)
res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20)
res.Impact, _ = store.FindImpactScoped(sym.Name, sym.Language, 2, 20)
case "class", "struct", "type", "interface", "trait", "enum", "object", "mixin", "extension", "protocol", "record", "actor":
res.Kind = "type"
res.Members, _ = store.ChildSymbols(sym.Name, 50, sym.File)
res.Refs, _ = store.FindReferences(sym.Name, 20)
res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20)
res.Implementors, _ = store.FindImplementors(sym.Name, 20)
res.Implements, _ = store.FindImplements(sym.Name, 20)
default:
res.Kind = sym.Kind
res.Refs, _ = store.FindReferences(sym.Name, 20)
res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20)
}

return res, nil
Expand Down
80 changes: 80 additions & 0 deletions index/index_phase3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,83 @@ func implementorsContain(results []ImplementorResult, implementer, target string
}
return false
}

// Same-name symbols across languages must not pollute each other's refs.
// Before the FindReferencesScoped fix, `investigate App` on the Go struct
// returned the TSX function's call site too because refs were name-only.
func TestInvestigateScopesRefsByLanguage(t *testing.T) {
t.Setenv("CYMBAL_CACHE_DIR", t.TempDir())
repo := t.TempDir()
writePhase3File(t, repo, "go.mod", "module example.com/polyglot\n\ngo 1.25\n")
writePhase3File(t, repo, "backend.go", `package backend

type App struct {
Name string
}

func NewApp() *App {
return &App{Name: "go-app"}
}
`)
writePhase3File(t, repo, "frontend.tsx", `import React from 'react'

export function App() {
return null
}

function helper() {
return App()
}
`)
runPhase3Git(t, repo, "init")
runPhase3Git(t, repo, "add", ".")
runPhase3Git(t, repo, "-c", "user.name=Cymbal Test", "-c", "user.email=cymbal@example.invalid", "commit", "-m", "initial")
if _, err := Index(repo, "", Options{Workers: 1, Force: true}); err != nil {
t.Fatal(err)
}
dbPath, err := RepoDBPath(repo)
if err != nil {
t.Fatal(err)
}
t.Cleanup(CloseAll)

results, err := SymbolsByName(dbPath, "App")
if err != nil {
t.Fatal(err)
}
var goSym, tsxSym SymbolResult
for _, r := range results {
switch r.Language {
case "go":
goSym = r
case "tsx", "typescript":
tsxSym = r
}
}
if goSym.Name == "" || tsxSym.Name == "" {
t.Fatalf("expected both Go and TSX App symbols, got %+v", results)
}

goRes, err := InvestigateResolved(dbPath, goSym)
if err != nil {
t.Fatal(err)
}
for _, ref := range goRes.Refs {
if strings.HasSuffix(ref.RelPath, ".tsx") {
t.Fatalf("Go App refs leaked TSX ref %s:%d", ref.RelPath, ref.Line)
}
}

tsxRes, err := InvestigateResolved(dbPath, tsxSym)
if err != nil {
t.Fatal(err)
}
for _, ref := range tsxRes.Refs {
if strings.HasSuffix(ref.RelPath, ".go") {
t.Fatalf("TSX App refs leaked Go ref %s:%d", ref.RelPath, ref.Line)
}
}
if len(tsxRes.Refs) == 0 {
t.Fatal("TSX App should have at least one ref (helper calls App())")
}
}
88 changes: 83 additions & 5 deletions index/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,66 @@ type RefResult struct {
Name string `json:"name"`
}

// FindReferencesScoped is FindReferences with a language filter. When
// language is non-empty, only refs from files in that language are returned.
// Used by investigate paths where the target symbol is unambiguously resolved
// and a same-language scope avoids mixing in calls to a same-named symbol in
// another language (e.g. Go struct App vs TSX function App).
func (s *Store) FindReferencesScoped(name, language string, limit int, kinds ...string) ([]RefResult, error) {
if language == "" {
return s.FindReferences(name, limit, kinds...)
}
if len(kinds) == 0 {
rows, err := s.db.Query(`
SELECT f.path, f.rel_path, r.line, r.name
FROM refs r JOIN files f ON r.file_id = f.id
WHERE r.name = ? AND r.language = ?
ORDER BY f.rel_path, r.line
LIMIT ?
`, name, language, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []RefResult
for rows.Next() {
var r RefResult
if err := rows.Scan(&r.File, &r.RelPath, &r.Line, &r.Name); err != nil {
return nil, err
}
results = append(results, r)
}
return results, rows.Err()
}
kindPlaceholders := strings.Repeat("?,", len(kinds))
kindPlaceholders = kindPlaceholders[:len(kindPlaceholders)-1]
args := []interface{}{name, language}
for _, k := range kinds {
args = append(args, k)
}
args = append(args, limit)
rows, err := s.db.Query(`
SELECT f.path, f.rel_path, r.line, r.name
FROM refs r JOIN files f ON r.file_id = f.id
WHERE r.name = ? AND r.language = ? AND r.kind IN (`+kindPlaceholders+`)
ORDER BY f.rel_path, r.line
LIMIT ?
`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []RefResult
for rows.Next() {
var r RefResult
if err := rows.Scan(&r.File, &r.RelPath, &r.Line, &r.Name); err != nil {
return nil, err
}
results = append(results, r)
}
return results, rows.Err()
}

// FindReferences finds files that reference a symbol name.
// By default this surfaces any ref kind (call, use, implements); pass
// explicit kinds to restrict (e.g. "call" to skip type-mentions).
Expand Down Expand Up @@ -1589,6 +1649,14 @@ func (s *Store) FindTrace(symbolName string, depth, limit int, kinds ...string)

// FindImpact performs transitive caller analysis using BFS.
func (s *Store) FindImpact(symbolName string, depth, limit int) ([]ImpactResult, error) {
return s.FindImpactScoped(symbolName, "", depth, limit)
}

// FindImpactScoped is FindImpact with a language filter. When language is
// non-empty, only refs and enclosing-symbols from files in that language are
// followed — used by investigate to keep cross-language same-name symbols
// from polluting transitive caller chains.
func (s *Store) FindImpactScoped(symbolName, language string, depth, limit int) ([]ImpactResult, error) {
if depth <= 0 {
depth = 2
}
Expand All @@ -1603,11 +1671,21 @@ func (s *Store) FindImpact(symbolName string, depth, limit int) ([]ImpactResult,
for d := 1; d <= depth && len(currentSymbols) > 0 && len(results) < limit; d++ {
var nextSymbols []string
for _, sym := range currentSymbols {
rows, err := s.db.Query(`
SELECT f.path, f.rel_path, r.line, r.name
FROM refs r JOIN files f ON r.file_id = f.id
WHERE r.name = ?
`, sym)
var rows *sql.Rows
var err error
if language == "" {
rows, err = s.db.Query(`
SELECT f.path, f.rel_path, r.line, r.name
FROM refs r JOIN files f ON r.file_id = f.id
WHERE r.name = ?
`, sym)
} else {
rows, err = s.db.Query(`
SELECT f.path, f.rel_path, r.line, r.name
FROM refs r JOIN files f ON r.file_id = f.id
WHERE r.name = ? AND r.language = ?
`, sym, language)
}
if err != nil {
continue
}
Expand Down
4 changes: 2 additions & 2 deletions lang/lang_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestForFileExtensions(t *testing.T) {
{"index.js", "javascript"},
{"App.jsx", "javascript"},
{"index.ts", "typescript"},
{"App.tsx", "typescript"},
{"App.tsx", "tsx"},
{"lib.rs", "rust"},
{"app.rb", "ruby"},
{"Main.java", "java"},
Expand Down Expand Up @@ -122,7 +122,7 @@ func TestForFileSpecialFilenames(t *testing.T) {

func TestSupported(t *testing.T) {
// Languages with tree-sitter grammars
for _, name := range []string{"go", "python", "javascript", "typescript", "rust", "ruby", "java", "c", "cpp", "csharp", "dart", "swift", "kotlin", "lua", "php", "bash", "scala", "yaml", "elixir", "hcl", "protobuf"} {
for _, name := range []string{"go", "python", "javascript", "typescript", "tsx", "rust", "ruby", "java", "c", "cpp", "csharp", "dart", "swift", "kotlin", "lua", "php", "bash", "scala", "yaml", "elixir", "hcl", "protobuf"} {
if !Default.Supported(name) {
t.Errorf("Supported(%q) = false, want true", name)
}
Expand Down
10 changes: 9 additions & 1 deletion lang/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,17 @@ var Default = NewRegistry(
},
Language{
Name: "typescript",
Extensions: []string{".ts", ".tsx", ".mts", ".cts"},
Extensions: []string{".ts", ".mts", ".cts"},
TreeSitter: sitter.NewLanguage(tstypescript.LanguageTypescript()),
},
Language{
// .tsx uses the TSX grammar so JSX parses natively. Without this split,
// anonymous arrow fns inside JSX props were misclassified as `method
// async` because the plain TS grammar can't see JSX boundaries.
Name: "tsx",
Extensions: []string{".tsx"},
TreeSitter: sitter.NewLanguage(tstypescript.LanguageTSX()),
},
Language{
Name: "rust",
Extensions: []string{".rs"},
Expand Down
Loading
Loading