mirror of
https://github.com/imjasonh/npm-snoop
synced 2026-07-08 00:55:28 +00:00
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>
320 lines
9.3 KiB
Go
320 lines
9.3 KiB
Go
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)
|