1
0
Fork 0
mirror of https://github.com/imjasonh/npm-snoop synced 2026-07-07 00:32:54 +00:00

Add ast-diff CLI for diffing symbols between npm package versions

Uses tree-sitter to parse JS/TS files and extract top-level symbols
(functions, classes, variables, methods), then diffs them by content
hash to identify added/removed/modified symbols between two versions.
Intended to help correlate CVE fixes with specific changed functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Hall 2026-03-26 16:52:19 -04:00
parent a5eda95590
commit 76af9702ce
11 changed files with 1401 additions and 2 deletions

5
.gitignore vendored
View file

@ -1,5 +1,6 @@
# Go binary
npm-snoop
# Go binaries
/npm-snoop
/ast-diff
# Terraform
terraform/.terraform/

110
ast-diff-plan.md Normal file
View file

@ -0,0 +1,110 @@
# AST Diff Service Plan
## Goal
Build a service that subscribes to `dev.npm.package.released.v1` events, downloads the new and previous version tarballs, parses all JS/TS files with tree-sitter, diffs the symbols (functions, classes, variables), and emits a `dev.npm.package.diff.v1` event describing what changed. This enables correlating CVE reports with specific changed symbols.
## Architecture
```
[broker] ---> [ast-diff service] ---> [broker] ---> [recorder/BQ]
(subscribes to (emits
released.v1) diff.v1)
```
- A new Cloud Run service, deployed alongside the existing poller.
- Subscribes to `dev.npm.package.released.v1` via the existing broker.
- Emits `dev.npm.package.diff.v1` back to the broker.
- Recorded to BigQuery via the existing recorder module.
## Steps
### 1. Scaffold the new service (`cmd/ast-diff/main.go`)
- Cloud Run HTTP service that receives CloudEvents.
- Receives `dev.npm.package.released.v1` events containing `name`, `version`, etc.
- Env config: `K_SINK` (broker ingress URL for sending diff events).
### 2. Find the previous version
- Fetch package metadata from `https://registry.npmjs.org/<name>`.
- Extract all version strings from the `versions` object keys.
- Parse as semver, sort, find the version immediately before the new one.
- Skip if this is the first version (nothing to diff against).
- Use `github.com/Masterminds/semver/v3` for parsing/sorting.
### 3. Download and extract tarballs
- npm tarballs are at `https://registry.npmjs.org/<name>/-/<basename>-<version>.tgz`.
- For scoped packages: `https://registry.npmjs.org/<@scope/name>/-/<name>-<version>.tgz`.
- Download both versions, decompress (gzip), untar to temp dirs.
- All files are under a `package/` prefix in the tarball.
### 4. Parse JS/TS files with tree-sitter
- Use `github.com/tree-sitter/go-tree-sitter` with JavaScript and TypeScript grammars.
- Walk both extracted dirs, parse every `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, `.cts` file.
- For each file, extract symbols: function declarations, class declarations, variable declarations, method definitions. Record name + kind + file path + line range.
- Build a map of `file:symbol_name:kind` -> source text (or hash of source text) for each version.
### 5. Diff the symbol maps
- Compare old vs new symbol maps:
- **Added**: symbols in new but not old
- **Removed**: symbols in old but not new
- **Modified**: symbols in both but with different source text/hash
- For modified symbols, the source text hash is enough — we don't need a fine-grained AST diff, just "this function changed".
### 6. Emit diff event
Event type: `dev.npm.package.diff.v1`
```json
{
"name": "@scope/package",
"old_version": "1.2.3",
"new_version": "1.2.4",
"symbols_added": [
{"name": "bar", "kind": "function", "file": "lib/utils.js"}
],
"symbols_removed": [...],
"symbols_modified": [
{"name": "foo", "kind": "function", "file": "lib/handler.js"}
],
"files_added": ["lib/new.js"],
"files_removed": ["lib/old.js"]
}
```
### 7. BigQuery schema (`terraform/schema-diff.json`)
Fields: `name`, `old_version`, `new_version`, `symbols_added` (JSON string), `symbols_removed` (JSON string), `symbols_modified` (JSON string), `files_added` (JSON string), `files_removed` (JSON string).
### 8. Terraform changes
- New `regional-go-service` module for the ast-diff service.
- Subscribe it to `dev.npm.package.released.v1` from the broker.
- Authorize it to publish back to the broker.
- Add `dev.npm.package.diff.v1` to the recorder's types.
- Service account with minimal permissions.
## Future work
- **TypeScript type/interface extraction**: `.d.ts` files contain `interface` and `type` declarations that we don't currently extract. Less relevant for CVE analysis (no runtime behavior) but could matter for API surface diffing.
## Key considerations
- **Tarball size**: Some packages are huge. We should set a size limit (say 50MB) and skip packages over that threshold.
- **Timeout**: Parsing large packages will take time. Cloud Run allows up to 60 min; we should set a reasonable timeout (5 min?) and skip if exceeded.
- **Scoped packages**: Tarball URL format differs for `@scope/name` packages — the tarball name is just `name-version.tgz` without the scope.
- **Pre-releases**: Semver sorting handles pre-releases naturally. We should diff pre-releases against their base version's predecessor.
- **Binary/minified files**: tree-sitter will parse minified JS fine but the symbols will be mangled. We could skip `.min.js` files. Detecting bundled/minified code more broadly is a stretch goal.
## Implementation order
1. Scaffold service + tarball download/extraction
2. tree-sitter parsing + symbol extraction
3. Diffing logic
4. CloudEvent emission
5. Terraform infra (service, subscription, BQ schema)
6. Testing with real packages

166
cmd/ast-diff/main.go Normal file
View file

@ -0,0 +1,166 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"github.com/imjasonh/npm-snoop/internal/diff"
"github.com/imjasonh/npm-snoop/internal/npm"
"github.com/imjasonh/npm-snoop/internal/symbols"
)
func main() {
pkg := flag.String("package", "", "npm package name (e.g. express, @babel/core)")
version := flag.String("version", "", "new version to diff (default: latest)")
oldVersion := flag.String("old-version", "", "old version to diff against (default: auto-detect previous)")
jsonOutput := flag.Bool("json", false, "output as JSON")
flag.Parse()
if *pkg == "" {
fmt.Fprintf(os.Stderr, "Usage: ast-diff -package <name> [-version <new>] [-old-version <old>]\n")
os.Exit(1)
}
log.Printf("fetching metadata for %s", *pkg)
meta, err := npm.FetchMetadata(*pkg)
if err != nil {
log.Fatalf("fetching metadata: %v", err)
}
newVer := *version
if newVer == "" {
newVer = meta.DistTags["latest"]
if newVer == "" {
log.Fatal("no latest version found")
}
}
oldVer := *oldVersion
if oldVer == "" {
versions := npm.VersionList(meta)
oldVer, err = npm.PreviousVersion(versions, newVer)
if err != nil {
log.Fatalf("finding previous version: %v", err)
}
}
log.Printf("diffing %s: %s -> %s", *pkg, oldVer, newVer)
oldDir, newDir, err := downloadBoth(*pkg, oldVer, newVer)
if err != nil {
log.Fatalf("downloading: %v", err)
}
defer os.RemoveAll(oldDir)
defer os.RemoveAll(newDir)
log.Printf("extracting symbols from %s@%s", *pkg, oldVer)
oldSyms, oldHashes, err := symbols.ExtractFromDir(oldDir)
if err != nil {
log.Fatalf("extracting symbols from old version: %v", err)
}
log.Printf("extracting symbols from %s@%s", *pkg, newVer)
newSyms, newHashes, err := symbols.ExtractFromDir(newDir)
if err != nil {
log.Fatalf("extracting symbols from new version: %v", err)
}
added, removed, modified := diff.Diff(oldSyms, oldHashes, newSyms, newHashes)
filesAdded, filesRemoved := diff.FileDiff(oldSyms, newSyms)
result := diff.Result{
Name: *pkg,
OldVersion: oldVer,
NewVersion: newVer,
Added: added,
Removed: removed,
Modified: modified,
FilesAdded: filesAdded,
FilesRemoved: filesRemoved,
}
if *jsonOutput {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(result); err != nil {
log.Fatalf("encoding JSON: %v", err)
}
return
}
printResult(result)
}
func downloadBoth(pkg, oldVer, newVer string) (string, string, error) {
oldURL := npm.TarballURL(pkg, oldVer)
newURL := npm.TarballURL(pkg, newVer)
log.Printf("downloading %s@%s", pkg, oldVer)
oldDir, err := npm.DownloadAndExtract(oldURL)
if err != nil {
return "", "", fmt.Errorf("old version: %w", err)
}
log.Printf("downloading %s@%s", pkg, newVer)
newDir, err := npm.DownloadAndExtract(newURL)
if err != nil {
os.RemoveAll(oldDir)
return "", "", fmt.Errorf("new version: %w", err)
}
return oldDir, newDir, nil
}
func printResult(r diff.Result) {
fmt.Printf("%s: %s -> %s\n\n", r.Name, r.OldVersion, r.NewVersion)
if len(r.FilesAdded) > 0 {
fmt.Printf("Files added (%d):\n", len(r.FilesAdded))
for _, f := range r.FilesAdded {
fmt.Printf(" + %s\n", f)
}
fmt.Println()
}
if len(r.FilesRemoved) > 0 {
fmt.Printf("Files removed (%d):\n", len(r.FilesRemoved))
for _, f := range r.FilesRemoved {
fmt.Printf(" - %s\n", f)
}
fmt.Println()
}
if len(r.Added) > 0 {
fmt.Printf("Symbols added (%d):\n", len(r.Added))
for _, s := range r.Added {
fmt.Printf(" + [%s] %s (%s)\n", s.Kind, s.Name, s.File)
}
fmt.Println()
}
if len(r.Removed) > 0 {
fmt.Printf("Symbols removed (%d):\n", len(r.Removed))
for _, s := range r.Removed {
fmt.Printf(" - [%s] %s (%s)\n", s.Kind, s.Name, s.File)
}
fmt.Println()
}
if len(r.Modified) > 0 {
fmt.Printf("Symbols modified (%d):\n", len(r.Modified))
for _, s := range r.Modified {
fmt.Printf(" ~ [%s] %s (%s)\n", s.Kind, s.Name, s.File)
}
fmt.Println()
}
total := len(r.Added) + len(r.Removed) + len(r.Modified)
if total == 0 {
fmt.Println("No symbol changes detected.")
} else {
fmt.Printf("Total: %d added, %d removed, %d modified\n", len(r.Added), len(r.Removed), len(r.Modified))
}
}

5
go.mod
View file

@ -21,6 +21,7 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
@ -34,10 +35,14 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mattn/go-pointer v0.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/tree-sitter/go-tree-sitter v0.25.0 // indirect
github.com/tree-sitter/tree-sitter-javascript v0.25.0 // indirect
github.com/tree-sitter/tree-sitter-typescript v0.23.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect

10
go.sum
View file

@ -28,6 +28,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chainguard-dev/clog v1.8.0 h1:frlTMEdg3XQR+ioQ6O9i92uigY8GTUcWKpuCFkhcCHA=
@ -74,6 +76,8 @@ github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -92,6 +96,12 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU=
github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU=
github.com/tree-sitter/tree-sitter-javascript v0.25.0 h1:ZkWETb66/w8cc13yhfnNuHOLDQWl3BnKlH6f9AdR88c=
github.com/tree-sitter/tree-sitter-javascript v0.25.0/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs=
github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU=
github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=

81
internal/diff/diff.go Normal file
View file

@ -0,0 +1,81 @@
package diff
import (
"sort"
"github.com/imjasonh/npm-snoop/internal/symbols"
)
// Result holds the diff between two versions of a package.
type Result struct {
Name string `json:"name"`
OldVersion string `json:"old_version"`
NewVersion string `json:"new_version"`
Added []symbols.Symbol `json:"symbols_added,omitempty"`
Removed []symbols.Symbol `json:"symbols_removed,omitempty"`
Modified []symbols.Symbol `json:"symbols_modified,omitempty"`
FilesAdded []string `json:"files_added,omitempty"`
FilesRemoved []string `json:"files_removed,omitempty"`
}
// Diff compares two symbol maps and returns the differences.
func Diff(
oldSyms map[string]symbols.Symbol, oldHashes symbols.SymbolMap,
newSyms map[string]symbols.Symbol, newHashes symbols.SymbolMap,
) (added, removed, modified []symbols.Symbol) {
for key, sym := range newSyms {
if _, ok := oldSyms[key]; !ok {
added = append(added, sym)
} else if oldHashes[key] != newHashes[key] {
modified = append(modified, sym)
}
}
for key, sym := range oldSyms {
if _, ok := newSyms[key]; !ok {
removed = append(removed, sym)
}
}
sortSymbols(added)
sortSymbols(removed)
sortSymbols(modified)
return
}
// FileDiff returns lists of files that were added or removed.
func FileDiff(oldSyms, newSyms map[string]symbols.Symbol) (added, removed []string) {
oldFiles := fileSet(oldSyms)
newFiles := fileSet(newSyms)
for f := range newFiles {
if !oldFiles[f] {
added = append(added, f)
}
}
for f := range oldFiles {
if !newFiles[f] {
removed = append(removed, f)
}
}
sort.Strings(added)
sort.Strings(removed)
return
}
func fileSet(syms map[string]symbols.Symbol) map[string]bool {
files := make(map[string]bool)
for _, sym := range syms {
files[sym.File] = true
}
return files
}
func sortSymbols(syms []symbols.Symbol) {
sort.Slice(syms, func(i, j int) bool {
if syms[i].File != syms[j].File {
return syms[i].File < syms[j].File
}
return syms[i].Name < syms[j].Name
})
}

112
internal/diff/diff_test.go Normal file
View file

@ -0,0 +1,112 @@
package diff
import (
"testing"
"github.com/imjasonh/npm-snoop/internal/symbols"
)
func TestDiff_Added(t *testing.T) {
oldSyms := map[string]symbols.Symbol{}
oldHashes := symbols.SymbolMap{}
newSyms := map[string]symbols.Symbol{
"lib.js:foo:function": {Name: "foo", Kind: "function", File: "lib.js"},
}
newHashes := symbols.SymbolMap{
"lib.js:foo:function": "abc123",
}
added, removed, modified := Diff(oldSyms, oldHashes, newSyms, newHashes)
if len(added) != 1 || added[0].Name != "foo" {
t.Errorf("expected foo added, got %v", added)
}
if len(removed) != 0 {
t.Errorf("expected no removed, got %v", removed)
}
if len(modified) != 0 {
t.Errorf("expected no modified, got %v", modified)
}
}
func TestDiff_Removed(t *testing.T) {
oldSyms := map[string]symbols.Symbol{
"lib.js:bar:variable": {Name: "bar", Kind: "variable", File: "lib.js"},
}
oldHashes := symbols.SymbolMap{
"lib.js:bar:variable": "abc123",
}
newSyms := map[string]symbols.Symbol{}
newHashes := symbols.SymbolMap{}
added, removed, modified := Diff(oldSyms, oldHashes, newSyms, newHashes)
if len(added) != 0 {
t.Errorf("expected no added, got %v", added)
}
if len(removed) != 1 || removed[0].Name != "bar" {
t.Errorf("expected bar removed, got %v", removed)
}
if len(modified) != 0 {
t.Errorf("expected no modified, got %v", modified)
}
}
func TestDiff_Modified(t *testing.T) {
key := "lib.js:foo:function"
sym := symbols.Symbol{Name: "foo", Kind: "function", File: "lib.js"}
oldSyms := map[string]symbols.Symbol{key: sym}
oldHashes := symbols.SymbolMap{key: "hash_v1"}
newSyms := map[string]symbols.Symbol{key: sym}
newHashes := symbols.SymbolMap{key: "hash_v2"}
added, removed, modified := Diff(oldSyms, oldHashes, newSyms, newHashes)
if len(added) != 0 {
t.Errorf("expected no added, got %v", added)
}
if len(removed) != 0 {
t.Errorf("expected no removed, got %v", removed)
}
if len(modified) != 1 || modified[0].Name != "foo" {
t.Errorf("expected foo modified, got %v", modified)
}
}
func TestDiff_Unchanged(t *testing.T) {
key := "lib.js:foo:function"
sym := symbols.Symbol{Name: "foo", Kind: "function", File: "lib.js"}
oldSyms := map[string]symbols.Symbol{key: sym}
oldHashes := symbols.SymbolMap{key: "same_hash"}
newSyms := map[string]symbols.Symbol{key: sym}
newHashes := symbols.SymbolMap{key: "same_hash"}
added, removed, modified := Diff(oldSyms, oldHashes, newSyms, newHashes)
if len(added)+len(removed)+len(modified) != 0 {
t.Errorf("expected no changes, got %d added, %d removed, %d modified",
len(added), len(removed), len(modified))
}
}
func TestFileDiff(t *testing.T) {
oldSyms := map[string]symbols.Symbol{
"old.js:foo:function": {Name: "foo", Kind: "function", File: "old.js"},
"both.js:bar:variable": {Name: "bar", Kind: "variable", File: "both.js"},
}
newSyms := map[string]symbols.Symbol{
"new.js:baz:function": {Name: "baz", Kind: "function", File: "new.js"},
"both.js:bar:variable": {Name: "bar", Kind: "variable", File: "both.js"},
}
added, removed := FileDiff(oldSyms, newSyms)
if len(added) != 1 || added[0] != "new.js" {
t.Errorf("expected new.js added, got %v", added)
}
if len(removed) != 1 || removed[0] != "old.js" {
t.Errorf("expected old.js removed, got %v", removed)
}
}

180
internal/npm/npm.go Normal file
View file

@ -0,0 +1,180 @@
package npm
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"github.com/Masterminds/semver/v3"
)
type PackageMetadata struct {
Name string `json:"name"`
Versions map[string]json.RawMessage `json:"versions"`
DistTags map[string]string `json:"dist-tags"`
Time map[string]string `json:"time"`
}
// FetchMetadata fetches package metadata from the npm registry.
func FetchMetadata(name string) (*PackageMetadata, error) {
url := fmt.Sprintf("https://registry.npmjs.org/%s", name)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("fetching %s: %w", name, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("package %s not found", name)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d for %s", resp.StatusCode, name)
}
var meta PackageMetadata
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
return nil, fmt.Errorf("decoding %s: %w", name, err)
}
return &meta, nil
}
// PreviousVersion finds the version immediately before the given version
// using semver sorting. Only considers stable versions unless the target
// is itself a pre-release.
func PreviousVersion(versions []string, target string) (string, error) {
targetV, err := semver.NewVersion(target)
if err != nil {
return "", fmt.Errorf("parsing target version %q: %w", target, err)
}
var parsed []*semver.Version
for _, v := range versions {
sv, err := semver.NewVersion(v)
if err != nil {
continue
}
// Skip pre-releases unless the target is also a pre-release.
if targetV.Prerelease() == "" && sv.Prerelease() != "" {
continue
}
if sv.LessThan(targetV) {
parsed = append(parsed, sv)
}
}
if len(parsed) == 0 {
return "", fmt.Errorf("no previous version found for %s", target)
}
sort.Sort(semver.Collection(parsed))
return parsed[len(parsed)-1].Original(), nil
}
// VersionList returns all version strings from the metadata.
func VersionList(meta *PackageMetadata) []string {
versions := make([]string, 0, len(meta.Versions))
for v := range meta.Versions {
versions = append(versions, v)
}
return versions
}
// TarballURL returns the npm tarball URL for a given package and version.
func TarballURL(name, version string) string {
// Scoped packages: @scope/pkg -> @scope/pkg/-/pkg-version.tgz
// Unscoped packages: pkg -> pkg/-/pkg-version.tgz
basename := name
if strings.HasPrefix(name, "@") {
parts := strings.SplitN(name, "/", 2)
if len(parts) == 2 {
basename = parts[1]
}
}
return fmt.Sprintf("https://registry.npmjs.org/%s/-/%s-%s.tgz", name, basename, version)
}
// DownloadAndExtract downloads a tarball and extracts it to a temp directory.
// Returns the path to the extracted directory.
func DownloadAndExtract(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", fmt.Errorf("downloading %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url)
}
dir, err := os.MkdirTemp("", "npm-snoop-*")
if err != nil {
return "", fmt.Errorf("creating temp dir: %w", err)
}
if err := extractTarGz(resp.Body, dir); err != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("extracting: %w", err)
}
return dir, nil
}
const maxFileSize = 10 * 1024 * 1024 // 10MB per file
func extractTarGz(r io.Reader, dest string) error {
gz, err := gzip.NewReader(r)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
// npm tarballs have a "package/" prefix.
name := strings.TrimPrefix(hdr.Name, "package/")
if name == "" || strings.Contains(name, "..") {
continue
}
target := filepath.Join(dest, name)
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if hdr.Size > maxFileSize {
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
f, err := os.Create(target)
if err != nil {
return err
}
if _, err := io.Copy(f, io.LimitReader(tr, maxFileSize)); err != nil {
f.Close()
return err
}
f.Close()
}
}
return nil
}

97
internal/npm/npm_test.go Normal file
View file

@ -0,0 +1,97 @@
package npm
import (
"encoding/json"
"testing"
)
func TestPreviousVersion(t *testing.T) {
versions := []string{"1.0.0", "1.1.0", "1.2.0", "2.0.0", "2.1.0"}
tests := []struct {
target string
want string
}{
{"2.1.0", "2.0.0"},
{"2.0.0", "1.2.0"},
{"1.1.0", "1.0.0"},
{"1.2.0", "1.1.0"},
}
for _, tt := range tests {
got, err := PreviousVersion(versions, tt.target)
if err != nil {
t.Errorf("PreviousVersion(%q): %v", tt.target, err)
continue
}
if got != tt.want {
t.Errorf("PreviousVersion(%q) = %q, want %q", tt.target, got, tt.want)
}
}
}
func TestPreviousVersion_NoPrevious(t *testing.T) {
versions := []string{"1.0.0"}
_, err := PreviousVersion(versions, "1.0.0")
if err == nil {
t.Error("expected error for first version, got nil")
}
}
func TestPreviousVersion_SkipsPrerelease(t *testing.T) {
versions := []string{"1.0.0", "1.1.0-beta.1", "1.1.0-rc.1", "1.1.0"}
got, err := PreviousVersion(versions, "1.1.0")
if err != nil {
t.Fatal(err)
}
if got != "1.0.0" {
t.Errorf("got %q, want 1.0.0 (should skip pre-releases)", got)
}
}
func TestPreviousVersion_PrereleaseToPrevious(t *testing.T) {
versions := []string{"1.0.0", "1.1.0-beta.1", "1.1.0-rc.1"}
// When target is a pre-release, include other pre-releases.
got, err := PreviousVersion(versions, "1.1.0-rc.1")
if err != nil {
t.Fatal(err)
}
if got != "1.1.0-beta.1" {
t.Errorf("got %q, want 1.1.0-beta.1", got)
}
}
func TestTarballURL(t *testing.T) {
tests := []struct {
name, version, want string
}{
{"express", "4.21.2", "https://registry.npmjs.org/express/-/express-4.21.2.tgz"},
{"@babel/core", "7.26.0", "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz"},
{"@types/node", "22.0.0", "https://registry.npmjs.org/@types/node/-/node-22.0.0.tgz"},
}
for _, tt := range tests {
got := TarballURL(tt.name, tt.version)
if got != tt.want {
t.Errorf("TarballURL(%q, %q) = %q, want %q", tt.name, tt.version, got, tt.want)
}
}
}
func TestVersionList(t *testing.T) {
meta := &PackageMetadata{
Versions: map[string]json.RawMessage{
"1.0.0": nil,
"1.1.0": nil,
"2.0.0": nil,
},
}
versions := VersionList(meta)
if len(versions) != 3 {
t.Errorf("expected 3 versions, got %d", len(versions))
}
}

320
internal/symbols/symbols.go Normal file
View file

@ -0,0 +1,320 @@
package symbols
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"strings"
"unsafe"
tree_sitter "github.com/tree-sitter/go-tree-sitter"
tree_sitter_javascript "github.com/tree-sitter/tree-sitter-javascript/bindings/go"
tree_sitter_typescript "github.com/tree-sitter/tree-sitter-typescript/bindings/go"
)
// Symbol represents a named declaration in a JS/TS file.
type Symbol struct {
Name string `json:"name"`
Kind string `json:"kind"` // "function", "class", "variable", "method"
File string `json:"file"`
}
// SymbolMap maps symbol keys (file:name:kind) to a hash of their source text.
type SymbolMap map[string]string
// Key returns the unique key for a symbol.
func (s Symbol) Key() string {
return s.File + ":" + s.Name + ":" + s.Kind
}
// ExtractFromDir walks a directory and extracts symbols from all JS/TS files.
func ExtractFromDir(dir string) (map[string]Symbol, SymbolMap, error) {
symbols := make(map[string]Symbol)
hashes := make(SymbolMap)
jsLang := tree_sitter.NewLanguage(tree_sitter_javascript.Language())
tsLang := tree_sitter.NewLanguage(tree_sitter_typescript.LanguageTypescript())
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip unreadable files
}
if info.IsDir() {
base := filepath.Base(path)
if base == "node_modules" || base == ".git" {
return filepath.SkipDir
}
return nil
}
ext := filepath.Ext(path)
var lang *tree_sitter.Language
switch ext {
case ".js", ".mjs", ".cjs":
lang = jsLang
case ".ts", ".mts", ".cts":
lang = tsLang
default:
return nil
}
// Skip minified files.
if strings.HasSuffix(strings.TrimSuffix(path, ext), ".min") {
return nil
}
relPath, _ := filepath.Rel(dir, path)
src, err := os.ReadFile(path)
if err != nil {
return nil
}
extracted, err := extractSymbols(src, lang, relPath)
if err != nil {
return nil // skip unparseable files
}
for _, sym := range extracted {
symbols[sym.sym.Key()] = sym.sym
hashes[sym.sym.Key()] = sym.hash
}
return nil
})
return symbols, hashes, err
}
type extractedSymbol struct {
sym Symbol
hash string
}
func extractSymbols(src []byte, lang *tree_sitter.Language, file string) ([]extractedSymbol, error) {
parser := tree_sitter.NewParser()
defer parser.Close()
if err := parser.SetLanguage(lang); err != nil {
return nil, err
}
tree := parser.Parse(src, nil)
defer tree.Close()
root := tree.RootNode()
var result []extractedSymbol
walkTopLevel(root, src, file, &result)
return result, nil
}
// walkTopLevel extracts declarations only at the module/program level.
// It recurses through structural nodes (program, export_statement, if_statement
// at top level, etc.) but does NOT descend into function/method bodies.
func walkTopLevel(node *tree_sitter.Node, src []byte, file string, result *[]extractedSymbol) {
kind := node.Kind()
switch kind {
case "function_declaration", "generator_function_declaration":
if name := childFieldText(node, "name", src); name != "" {
*result = append(*result, makeSymbol(name, "function", file, node, src))
}
return // don't recurse into function body
case "class_declaration":
if name := childFieldText(node, "name", src); name != "" {
*result = append(*result, makeSymbol(name, "class", file, node, src))
}
// Extract methods within the class.
if body := node.ChildByFieldName("body"); body != nil {
for i := uint(0); i < body.NamedChildCount(); i++ {
child := body.NamedChild(i)
if child.Kind() == "method_definition" {
if mname := childFieldText(child, "name", src); mname != "" {
qualName := childFieldText(node, "name", src) + "." + mname
*result = append(*result, makeSymbol(qualName, "method", file, child, src))
}
}
}
}
return
case "lexical_declaration", "variable_declaration":
for i := uint(0); i < node.NamedChildCount(); i++ {
child := node.NamedChild(i)
if child.Kind() == "variable_declarator" {
nameNode := child.ChildByFieldName("name")
if nameNode == nil {
continue
}
switch nameNode.Kind() {
case "identifier":
name := nameNode.Utf8Text(src)
declKind := "variable"
if val := child.ChildByFieldName("value"); val != nil {
switch val.Kind() {
case "arrow_function", "function_expression", "generator_function":
declKind = "function"
}
}
*result = append(*result, makeSymbol(name, declKind, file, node, src))
case "object_pattern":
// Destructuring: const { a, b: c } = ...
// Extract each bound identifier.
for _, name := range extractDestructuredNames(nameNode, src) {
*result = append(*result, makeSymbol(name, "variable", file, node, src))
}
case "array_pattern":
for _, name := range extractDestructuredNames(nameNode, src) {
*result = append(*result, makeSymbol(name, "variable", file, node, src))
}
}
}
}
return
case "export_statement":
if decl := node.ChildByFieldName("declaration"); decl != nil {
walkTopLevel(decl, src, file, result)
return
}
case "expression_statement":
// Handle module.exports = ... and module.exports.foo = ...
extractModuleExports(node, src, file, result)
return
}
// Only recurse into structural containers, not into function/class bodies.
for i := uint(0); i < node.NamedChildCount(); i++ {
child := node.NamedChild(i)
switch child.Kind() {
case "function_declaration", "generator_function_declaration",
"class_declaration",
"lexical_declaration", "variable_declaration",
"export_statement",
"expression_statement":
walkTopLevel(child, src, file, result)
}
}
}
// extractDestructuredNames extracts bound identifiers from destructuring patterns.
// For { a, b: c, d } it returns ["a", "c", "d"].
// For [a, b, c] it returns ["a", "b", "c"].
func extractDestructuredNames(node *tree_sitter.Node, src []byte) []string {
var names []string
for i := uint(0); i < node.NamedChildCount(); i++ {
child := node.NamedChild(i)
switch child.Kind() {
case "shorthand_property_identifier_pattern":
// { foo } -> foo
names = append(names, child.Utf8Text(src))
case "pair_pattern":
// { foo: bar } -> bar (the value side)
if val := child.ChildByFieldName("value"); val != nil {
if val.Kind() == "identifier" {
names = append(names, val.Utf8Text(src))
} else {
// Nested destructuring
names = append(names, extractDestructuredNames(val, src)...)
}
}
case "identifier":
// [a, b] -> a, b
names = append(names, child.Utf8Text(src))
case "rest_pattern":
// { ...rest } or [...rest]
for j := uint(0); j < child.NamedChildCount(); j++ {
if id := child.NamedChild(j); id.Kind() == "identifier" {
names = append(names, id.Utf8Text(src))
}
}
case "object_pattern", "array_pattern":
// Nested destructuring
names = append(names, extractDestructuredNames(child, src)...)
case "assignment_pattern", "object_assignment_pattern":
// { foo = defaultVal } -> foo
if left := child.ChildByFieldName("left"); left != nil {
switch left.Kind() {
case "identifier", "shorthand_property_identifier_pattern":
names = append(names, left.Utf8Text(src))
default:
names = append(names, extractDestructuredNames(left, src)...)
}
}
}
}
return names
}
// extractModuleExports handles CommonJS patterns like:
// module.exports = function() { ... }
// module.exports.foo = function() { ... }
// exports.foo = function() { ... }
func extractModuleExports(node *tree_sitter.Node, src []byte, file string, result *[]extractedSymbol) {
// Look for assignment_expression children.
for i := uint(0); i < node.NamedChildCount(); i++ {
child := node.NamedChild(i)
if child.Kind() != "assignment_expression" {
continue
}
left := child.ChildByFieldName("left")
right := child.ChildByFieldName("right")
if left == nil || right == nil {
continue
}
leftText := left.Utf8Text(src)
// module.exports.foo = ... or exports.foo = ...
if strings.HasPrefix(leftText, "module.exports.") {
name := strings.TrimPrefix(leftText, "module.exports.")
kind := "variable"
switch right.Kind() {
case "arrow_function", "function_expression", "function_declaration":
kind = "function"
}
*result = append(*result, makeSymbol(name, kind, file, child, src))
} else if strings.HasPrefix(leftText, "exports.") {
name := strings.TrimPrefix(leftText, "exports.")
kind := "variable"
switch right.Kind() {
case "arrow_function", "function_expression", "function_declaration":
kind = "function"
}
*result = append(*result, makeSymbol(name, kind, file, child, src))
}
}
}
func childFieldText(node *tree_sitter.Node, field string, src []byte) string {
child := node.ChildByFieldName(field)
if child == nil {
return ""
}
return child.Utf8Text(src)
}
func makeSymbol(name, kind, file string, node *tree_sitter.Node, src []byte) extractedSymbol {
text := nodeBytes(node, src)
h := sha256.Sum256(text)
return extractedSymbol{
sym: Symbol{Name: name, Kind: kind, File: file},
hash: fmt.Sprintf("%x", h[:8]),
}
}
func nodeBytes(node *tree_sitter.Node, src []byte) []byte {
start := node.StartByte()
end := node.EndByte()
if int(end) > len(src) {
end = uint(len(src))
}
return src[start:end]
}
// Ensure the unsafe import is used (needed by tree-sitter bindings).
var _ = unsafe.Pointer(nil)

View file

@ -0,0 +1,317 @@
package symbols
import (
"os"
"path/filepath"
"testing"
)
func TestExtractFromDir_FunctionDeclarations(t *testing.T) {
dir := writeTestFiles(t, map[string]string{
"lib/utils.js": `
function greet(name) {
return "hello " + name;
}
function farewell(name) {
var inner = "bye";
return inner + " " + name;
}
`,
})
syms, hashes, err := ExtractFromDir(dir)
if err != nil {
t.Fatal(err)
}
assertSymbol(t, syms, "lib/utils.js", "greet", "function")
assertSymbol(t, syms, "lib/utils.js", "farewell", "function")
// inner should NOT be extracted (it's inside a function body).
assertNoSymbol(t, syms, "lib/utils.js", "inner", "variable")
if len(syms) != 2 {
t.Errorf("expected 2 symbols, got %d: %v", len(syms), symbolNames(syms))
}
if len(hashes) != 2 {
t.Errorf("expected 2 hashes, got %d", len(hashes))
}
}
func TestExtractFromDir_ClassesAndMethods(t *testing.T) {
dir := writeTestFiles(t, map[string]string{
"animal.js": `
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return this.name + " speaks";
}
}
`,
})
syms, _, err := ExtractFromDir(dir)
if err != nil {
t.Fatal(err)
}
assertSymbol(t, syms, "animal.js", "Animal", "class")
assertSymbol(t, syms, "animal.js", "Animal.constructor", "method")
assertSymbol(t, syms, "animal.js", "Animal.speak", "method")
if len(syms) != 3 {
t.Errorf("expected 3 symbols, got %d: %v", len(syms), symbolNames(syms))
}
}
func TestExtractFromDir_Variables(t *testing.T) {
dir := writeTestFiles(t, map[string]string{
"config.js": `
const PORT = 3000;
let host = "localhost";
var debug = true;
const handler = (req, res) => { res.send("ok"); };
const process = function() { return 1; };
`,
})
syms, _, err := ExtractFromDir(dir)
if err != nil {
t.Fatal(err)
}
assertSymbol(t, syms, "config.js", "PORT", "variable")
assertSymbol(t, syms, "config.js", "host", "variable")
assertSymbol(t, syms, "config.js", "debug", "variable")
assertSymbol(t, syms, "config.js", "handler", "function")
assertSymbol(t, syms, "config.js", "process", "function")
}
func TestExtractFromDir_ExportedDeclarations(t *testing.T) {
dir := writeTestFiles(t, map[string]string{
"index.mjs": `
export function hello() { return "hi"; }
export const version = "1.0";
export class Server {}
`,
})
syms, _, err := ExtractFromDir(dir)
if err != nil {
t.Fatal(err)
}
assertSymbol(t, syms, "index.mjs", "hello", "function")
assertSymbol(t, syms, "index.mjs", "version", "variable")
assertSymbol(t, syms, "index.mjs", "Server", "class")
}
func TestExtractFromDir_ModuleExports(t *testing.T) {
dir := writeTestFiles(t, map[string]string{
"lib.js": `
module.exports.parse = function(input) { return input; };
exports.format = (data) => data.toString();
module.exports.VERSION = "2.0";
`,
})
syms, _, err := ExtractFromDir(dir)
if err != nil {
t.Fatal(err)
}
assertSymbol(t, syms, "lib.js", "parse", "function")
assertSymbol(t, syms, "lib.js", "format", "function")
assertSymbol(t, syms, "lib.js", "VERSION", "variable")
}
func TestExtractFromDir_TypeScript(t *testing.T) {
dir := writeTestFiles(t, map[string]string{
"index.ts": `
function greet(name: string): string {
return "hello " + name;
}
class Greeter {
greet(name: string): string {
return "hello " + name;
}
}
const VERSION: string = "1.0";
`,
})
syms, _, err := ExtractFromDir(dir)
if err != nil {
t.Fatal(err)
}
assertSymbol(t, syms, "index.ts", "greet", "function")
assertSymbol(t, syms, "index.ts", "Greeter", "class")
assertSymbol(t, syms, "index.ts", "Greeter.greet", "method")
assertSymbol(t, syms, "index.ts", "VERSION", "variable")
}
func TestExtractFromDir_SkipsMinified(t *testing.T) {
dir := writeTestFiles(t, map[string]string{
"lib.js": `function foo() { return 1; }`,
"lib.min.js": `function foo(){return 1}`,
})
syms, _, err := ExtractFromDir(dir)
if err != nil {
t.Fatal(err)
}
if len(syms) != 1 {
t.Errorf("expected 1 symbol (skipping .min.js), got %d", len(syms))
}
}
func TestExtractFromDir_SkipsNodeModules(t *testing.T) {
dir := writeTestFiles(t, map[string]string{
"index.js": `function app() {}`,
"node_modules/dep/index.js": `function dep() {}`,
})
syms, _, err := ExtractFromDir(dir)
if err != nil {
t.Fatal(err)
}
assertSymbol(t, syms, "index.js", "app", "function")
assertNoSymbol(t, syms, "node_modules/dep/index.js", "dep", "function")
}
func TestExtractFromDir_HashChanges(t *testing.T) {
dir1 := writeTestFiles(t, map[string]string{
"lib.js": `function foo() { return 1; }`,
})
dir2 := writeTestFiles(t, map[string]string{
"lib.js": `function foo() { return 2; }`,
})
_, h1, err := ExtractFromDir(dir1)
if err != nil {
t.Fatal(err)
}
_, h2, err := ExtractFromDir(dir2)
if err != nil {
t.Fatal(err)
}
key := "lib.js:foo:function"
if h1[key] == h2[key] {
t.Error("expected different hashes for modified function body")
}
}
func TestNoLocalVariables(t *testing.T) {
dir := writeTestFiles(t, map[string]string{
"handler.js": `
function handle(req, res) {
const body = req.body;
var result = process(body);
let formatted = format(result);
return formatted;
}
const config = { port: 3000 };
`,
})
syms, _, err := ExtractFromDir(dir)
if err != nil {
t.Fatal(err)
}
assertSymbol(t, syms, "handler.js", "handle", "function")
assertSymbol(t, syms, "handler.js", "config", "variable")
assertNoSymbol(t, syms, "handler.js", "body", "variable")
assertNoSymbol(t, syms, "handler.js", "result", "variable")
assertNoSymbol(t, syms, "handler.js", "formatted", "variable")
if len(syms) != 2 {
t.Errorf("expected 2 symbols, got %d: %v", len(syms), symbolNames(syms))
}
}
func TestExtractFromDir_Destructuring(t *testing.T) {
dir := writeTestFiles(t, map[string]string{
"lib.js": `
const { a, b: renamed, c } = require("something");
const [x, y, z] = getValues();
const { nested: { deep } } = config;
const { foo = "default" } = opts;
const { ...rest } = obj;
`,
})
syms, _, err := ExtractFromDir(dir)
if err != nil {
t.Fatal(err)
}
assertSymbol(t, syms, "lib.js", "a", "variable")
assertSymbol(t, syms, "lib.js", "renamed", "variable")
assertSymbol(t, syms, "lib.js", "c", "variable")
assertSymbol(t, syms, "lib.js", "x", "variable")
assertSymbol(t, syms, "lib.js", "y", "variable")
assertSymbol(t, syms, "lib.js", "z", "variable")
assertSymbol(t, syms, "lib.js", "deep", "variable")
assertSymbol(t, syms, "lib.js", "foo", "variable")
assertSymbol(t, syms, "lib.js", "rest", "variable")
// Make sure we don't have the raw destructuring text as a symbol name.
for _, sym := range syms {
if len(sym.Name) > 20 {
t.Errorf("suspiciously long symbol name (likely raw destructuring): %q", sym.Name)
}
}
}
// --- helpers ---
func writeTestFiles(t *testing.T, files map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, content := range files {
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}
func assertSymbol(t *testing.T, syms map[string]Symbol, file, name, kind string) {
t.Helper()
key := file + ":" + name + ":" + kind
if _, ok := syms[key]; !ok {
t.Errorf("expected symbol %s:%s (%s) not found", file, name, kind)
}
}
func assertNoSymbol(t *testing.T, syms map[string]Symbol, file, name, kind string) {
t.Helper()
key := file + ":" + name + ":" + kind
if _, ok := syms[key]; ok {
t.Errorf("unexpected symbol %s:%s (%s) found", file, name, kind)
}
}
func symbolNames(syms map[string]Symbol) []string {
var names []string
for k := range syms {
names = append(names, k)
}
return names
}