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:
parent
a5eda95590
commit
76af9702ce
11 changed files with 1401 additions and 2 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
# Go binary
|
||||
npm-snoop
|
||||
# Go binaries
|
||||
/npm-snoop
|
||||
/ast-diff
|
||||
|
||||
# Terraform
|
||||
terraform/.terraform/
|
||||
|
|
|
|||
110
ast-diff-plan.md
Normal file
110
ast-diff-plan.md
Normal 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
166
cmd/ast-diff/main.go
Normal 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
5
go.mod
|
|
@ -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
10
go.sum
|
|
@ -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
81
internal/diff/diff.go
Normal 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
112
internal/diff/diff_test.go
Normal 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
180
internal/npm/npm.go
Normal 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
97
internal/npm/npm_test.go
Normal 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
320
internal/symbols/symbols.go
Normal 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)
|
||||
317
internal/symbols/symbols_test.go
Normal file
317
internal/symbols/symbols_test.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue