chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,844 @@
|
||||
package languages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/parser"
|
||||
sitter "github.com/zzet/gortex/internal/parser/tsitter"
|
||||
"github.com/zzet/gortex/internal/parser/tsitter/javascript"
|
||||
)
|
||||
|
||||
// qJSAll is a single tree-sitter query alternating over every pattern
|
||||
// the JavaScript extractor needs. One tree walk per file replaces the
|
||||
// 9+ `parser.RunQuery` calls (counting the per-class jsQMethod re-run).
|
||||
// Capture names are disjoint across patterns so the dispatch in Extract
|
||||
// can branch on which name is set. Method-to-class membership uses a
|
||||
// parent walk on method_definition; the const-arrow-vs-var dedupe is
|
||||
// handled by emitting arrow first and skipping the var pattern when
|
||||
// the name is already owned by an arrow.
|
||||
const qJSAll = `
|
||||
[
|
||||
(function_declaration
|
||||
name: (identifier) @func.name) @func.def
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @arrow.name
|
||||
value: (arrow_function))) @arrow.def
|
||||
|
||||
(class_declaration
|
||||
name: (identifier) @class.name) @class.def
|
||||
|
||||
(pair
|
||||
key: (property_identifier) @objfn.name
|
||||
value: (arrow_function)) @objfn.def
|
||||
|
||||
(method_definition
|
||||
name: (property_identifier) @method.name) @method.def
|
||||
|
||||
(field_definition
|
||||
property: (property_identifier) @classarrow.name
|
||||
value: (arrow_function)) @classarrow.def
|
||||
|
||||
(import_statement
|
||||
source: (string (string_fragment) @import.path)) @import.def
|
||||
|
||||
(export_statement
|
||||
source: (string (string_fragment) @reexport.path)) @reexport.def
|
||||
|
||||
(call_expression
|
||||
function: (identifier) @req.name
|
||||
arguments: (arguments (string (string_fragment) @req.path))) @req.def
|
||||
|
||||
(call_expression
|
||||
function: (identifier) @call.name) @call.expr
|
||||
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
object: (_) @callm.receiver
|
||||
property: (property_identifier) @callm.method)) @callm.expr
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @var.name)) @var.def
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @varDecl.name)) @varDecl.def
|
||||
]
|
||||
`
|
||||
|
||||
// JavaScriptExtractor extracts JavaScript source files.
|
||||
type JavaScriptExtractor struct {
|
||||
lang *sitter.Language
|
||||
qAll *parser.PreparedQuery
|
||||
}
|
||||
|
||||
func NewJavaScriptExtractor() *JavaScriptExtractor {
|
||||
lang := javascript.GetLanguage()
|
||||
return &JavaScriptExtractor{
|
||||
lang: lang,
|
||||
qAll: parser.MustPreparedQuery(qJSAll, lang),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *JavaScriptExtractor) Language() string { return "javascript" }
|
||||
func (e *JavaScriptExtractor) Extensions() []string {
|
||||
// .xsjs / .xsjslib are SAP HANA XS server-side JavaScript — plain JS by
|
||||
// grammar, so the JS extractor's tree-sitter query mines their (typically
|
||||
// IIFE/AMD-wrapped) inner functions the same as any .js module.
|
||||
return []string{".js", ".jsx", ".mjs", ".cjs", ".xsjs", ".xsjslib"}
|
||||
}
|
||||
|
||||
// --- Deferred match buffers ----------------------------------------
|
||||
|
||||
type jsDeferredCall struct {
|
||||
name string
|
||||
receiver string // receiver text for member calls
|
||||
line int
|
||||
isMember bool
|
||||
// expr is the call_expression node, kept for member calls so the
|
||||
// post-pass can inspect arguments for pub/sub topic detection.
|
||||
expr *sitter.Node
|
||||
// returnUsage is how the call site consumes the return value
|
||||
// (graph.ReturnUsage* label), classified at capture time and
|
||||
// stamped as edge Meta on every EdgeCalls emitted for this site.
|
||||
returnUsage string
|
||||
}
|
||||
|
||||
type jsDeferredVar struct {
|
||||
name string
|
||||
defNode *sitter.Node
|
||||
line int
|
||||
endLine int
|
||||
}
|
||||
|
||||
func (e *JavaScriptExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
|
||||
tree, err := parser.ParseFile(src, e.lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tree.Close()
|
||||
|
||||
root := tree.RootNode()
|
||||
result := &parser.ExtractionResult{}
|
||||
|
||||
fileNode := &graph.Node{
|
||||
ID: filePath, Kind: graph.KindFile, Name: filePath,
|
||||
FilePath: filePath, StartLine: 1, EndLine: int(root.EndPoint().Row) + 1,
|
||||
Language: "javascript",
|
||||
}
|
||||
fileID := fileNode.ID
|
||||
result.Nodes = append(result.Nodes, fileNode)
|
||||
|
||||
arrowNames := make(map[string]bool)
|
||||
|
||||
// objLiteralMembers maps a top-level object-literal owner name to the
|
||||
// member-function node IDs declared inside it — method shorthand
|
||||
// (`{ process() {...} }`) and arrow fields (`{ health: () => ... }`).
|
||||
// A later `owner.method()` member call binds straight to the
|
||||
// registered node instead of falling through to name-only resolution,
|
||||
// which would otherwise mis-bind to an unrelated free function of the
|
||||
// same name elsewhere in the repo.
|
||||
objLiteralMembers := map[string]map[string]string{}
|
||||
registerObjMember := func(owner, member, id string) {
|
||||
if owner == "" || member == "" || id == "" {
|
||||
return
|
||||
}
|
||||
set := objLiteralMembers[owner]
|
||||
if set == nil {
|
||||
set = map[string]string{}
|
||||
objLiteralMembers[owner] = set
|
||||
}
|
||||
set[member] = id
|
||||
}
|
||||
|
||||
var calls []jsDeferredCall
|
||||
var vars []jsDeferredVar
|
||||
// importPaths collects every imported / required module path so the
|
||||
// post-pass can disambiguate generic pub/sub method names (emit / on
|
||||
// / send) and infer the broker transport.
|
||||
var importPaths []string
|
||||
// seenIDs: per-file node-ID collision set so two declarations sharing a
|
||||
// base ID (a redefined function, a class method redeclared) both survive
|
||||
// as distinct nodes instead of one overwriting the other.
|
||||
seenIDs := map[string]bool{}
|
||||
|
||||
parser.EachMatch(e.qAll, root, src, func(m parser.QueryResult) {
|
||||
switch {
|
||||
|
||||
case m.Captures["func.def"] != nil:
|
||||
e.emitFunction(m, filePath, fileID, src, result, seenIDs)
|
||||
|
||||
case m.Captures["arrow.def"] != nil:
|
||||
e.emitArrow(m, filePath, fileID, src, result, arrowNames, seenIDs)
|
||||
|
||||
case m.Captures["class.def"] != nil:
|
||||
e.emitClass(m, filePath, fileID, src, result)
|
||||
|
||||
case m.Captures["objfn.def"] != nil:
|
||||
registerObjMember(e.emitObjectArrowField(m, filePath, fileID, src, result))
|
||||
|
||||
case m.Captures["method.def"] != nil:
|
||||
registerObjMember(e.emitMethod(m, filePath, fileID, src, result, seenIDs))
|
||||
|
||||
case m.Captures["classarrow.def"] != nil:
|
||||
e.emitClassArrowField(m, filePath, fileID, src, result)
|
||||
|
||||
case m.Captures["import.def"] != nil:
|
||||
e.emitImport(m, filePath, fileID, src, result)
|
||||
if p := m.Captures["import.path"]; p != nil {
|
||||
importPaths = append(importPaths, p.Text)
|
||||
}
|
||||
|
||||
case m.Captures["reexport.def"] != nil:
|
||||
e.emitReExport(m, filePath, fileID, src, result)
|
||||
|
||||
case m.Captures["req.def"] != nil:
|
||||
e.emitRequire(m, filePath, fileID, result)
|
||||
if m.Captures["req.name"] != nil && m.Captures["req.name"].Text == "require" {
|
||||
if p := m.Captures["req.path"]; p != nil {
|
||||
importPaths = append(importPaths, p.Text)
|
||||
}
|
||||
}
|
||||
|
||||
case m.Captures["callm.expr"] != nil:
|
||||
expr := m.Captures["callm.expr"]
|
||||
dc := jsDeferredCall{
|
||||
name: m.Captures["callm.method"].Text,
|
||||
line: expr.StartLine + 1,
|
||||
isMember: true,
|
||||
expr: expr.Node,
|
||||
returnUsage: classifyReturnUsage(expr.Node, src, jsTSReturnUsageSpec),
|
||||
}
|
||||
if r := m.Captures["callm.receiver"]; r != nil {
|
||||
dc.receiver = r.Text
|
||||
}
|
||||
calls = append(calls, dc)
|
||||
|
||||
case m.Captures["call.expr"] != nil:
|
||||
expr := m.Captures["call.expr"]
|
||||
calls = append(calls, jsDeferredCall{
|
||||
name: m.Captures["call.name"].Text,
|
||||
line: expr.StartLine + 1,
|
||||
returnUsage: classifyReturnUsage(expr.Node, src, jsTSReturnUsageSpec),
|
||||
})
|
||||
|
||||
case m.Captures["var.def"] != nil:
|
||||
def := m.Captures["var.def"]
|
||||
vars = append(vars, jsDeferredVar{
|
||||
name: m.Captures["var.name"].Text,
|
||||
defNode: def.Node,
|
||||
line: def.StartLine + 1,
|
||||
endLine: def.EndLine + 1,
|
||||
})
|
||||
|
||||
case m.Captures["varDecl.def"] != nil:
|
||||
def := m.Captures["varDecl.def"]
|
||||
vars = append(vars, jsDeferredVar{
|
||||
name: m.Captures["varDecl.name"].Text,
|
||||
defNode: def.Node,
|
||||
line: def.StartLine + 1,
|
||||
endLine: def.EndLine + 1,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Module-level variable emission — skip names already emitted as
|
||||
// arrow functions (const-arrow-vs-var dedupe).
|
||||
for _, v := range vars {
|
||||
if arrowNames[v.name] {
|
||||
continue
|
||||
}
|
||||
parent := v.defNode.Parent()
|
||||
if parent != nil && parent.Type() == "export_statement" {
|
||||
parent = parent.Parent()
|
||||
}
|
||||
if parent == nil || parent.Type() != "program" {
|
||||
continue
|
||||
}
|
||||
id := filePath + "::" + v.name
|
||||
node := &graph.Node{
|
||||
ID: id, Kind: graph.KindVariable, Name: v.name,
|
||||
FilePath: filePath, StartLine: v.line, EndLine: v.endLine,
|
||||
Language: "javascript",
|
||||
}
|
||||
// React HOC / styled component classification + inline-render JSX
|
||||
// re-attribution (see the TS extractor for the shared helper).
|
||||
if v.name != "" && v.name[0] >= 'A' && v.name[0] <= 'Z' {
|
||||
if kind, renderFn := reactHOCComponentKind(v.defNode, src); kind != "" {
|
||||
node.Meta = map[string]any{"component": true, "component_kind": kind, "ui_component": jsxFrameworkFromImports(v.defNode, src)}
|
||||
if renderFn != nil {
|
||||
if body := renderFn.ChildByFieldName("body"); body != nil {
|
||||
emitJSXRenderEdges(id, body, src, filePath, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.Nodes = append(result.Nodes, node)
|
||||
result.Edges = append(result.Edges, &graph.Edge{
|
||||
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: v.line,
|
||||
})
|
||||
}
|
||||
|
||||
// Store-factory destructuring: `const {a,b} = useStore.getState()` binds
|
||||
// later bare `a()` / `b()` calls to the store's actions.
|
||||
destructured := map[string]string{} // local action name → store binding
|
||||
for _, dm := range jsDestructureGetStateRE.FindAllStringSubmatch(string(src), -1) {
|
||||
binding := dm[2]
|
||||
for _, nm := range strings.Split(dm[1], ",") {
|
||||
nm = strings.TrimSpace(nm)
|
||||
if i := strings.IndexByte(nm, ':'); i >= 0 { // {a: alias} → key on alias
|
||||
nm = strings.TrimSpace(nm[i+1:])
|
||||
}
|
||||
if nm != "" {
|
||||
destructured[nm] = binding
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve calls against funcRanges.
|
||||
funcRanges := buildFuncRanges(result)
|
||||
for _, c := range calls {
|
||||
callerID := findEnclosingFunc(funcRanges, c.line)
|
||||
if callerID == "" {
|
||||
// Module-top-level call, or a call inside an anonymous
|
||||
// callback that produced no function node: attribute the
|
||||
// call site to the file node instead of dropping it (same
|
||||
// rationale as the TypeScript extractor).
|
||||
callerID = fileID
|
||||
}
|
||||
if c.isMember {
|
||||
// Object-literal member call (`api.process()` where
|
||||
// `const api = { process() {...} }` is in this file): bind
|
||||
// straight to the member-function node. Resolved at
|
||||
// extraction — the resolver never sees an `unresolved::`
|
||||
// target, so a free function also named `process` in another
|
||||
// package can't capture this edge in the name-only fallback.
|
||||
if members, ok := objLiteralMembers[c.receiver]; ok {
|
||||
if memberID, ok := members[c.name]; ok {
|
||||
edge := &graph.Edge{
|
||||
From: callerID, To: memberID,
|
||||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||||
Origin: graph.OriginASTResolved, Confidence: 0.92,
|
||||
}
|
||||
stampReturnUsage(edge, c.returnUsage)
|
||||
result.Edges = append(result.Edges, edge)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Store-factory chained call: `useStore.getState().action()`.
|
||||
if binding, ok := jsParseGetStateChain(c.receiver); ok {
|
||||
if memberID := objLiteralMembers[binding][c.name]; memberID != "" {
|
||||
edge := &graph.Edge{
|
||||
From: callerID, To: memberID,
|
||||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||||
Origin: graph.OriginASTResolved, Confidence: 0.9,
|
||||
}
|
||||
stampReturnUsage(edge, c.returnUsage)
|
||||
result.Edges = append(result.Edges, edge)
|
||||
continue
|
||||
}
|
||||
edge := &graph.Edge{
|
||||
From: callerID, To: "unresolved::*." + c.name,
|
||||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||||
Meta: map[string]any{"via": "store-factory", "store_binding": binding, "store_action": c.name},
|
||||
}
|
||||
stampReturnUsage(edge, c.returnUsage)
|
||||
result.Edges = append(result.Edges, edge)
|
||||
continue
|
||||
}
|
||||
edge := &graph.Edge{
|
||||
From: callerID, To: "unresolved::*." + c.name,
|
||||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||||
}
|
||||
stampReturnUsage(edge, c.returnUsage)
|
||||
result.Edges = append(result.Edges, edge)
|
||||
continue
|
||||
}
|
||||
// Store-factory destructured call: `const {a}=useStore.getState(); a()`.
|
||||
if binding, ok := destructured[c.name]; ok {
|
||||
if memberID := objLiteralMembers[binding][c.name]; memberID != "" {
|
||||
edge := &graph.Edge{
|
||||
From: callerID, To: memberID,
|
||||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||||
Origin: graph.OriginASTResolved, Confidence: 0.9,
|
||||
}
|
||||
stampReturnUsage(edge, c.returnUsage)
|
||||
result.Edges = append(result.Edges, edge)
|
||||
continue
|
||||
}
|
||||
edge := &graph.Edge{
|
||||
From: callerID, To: "unresolved::" + c.name,
|
||||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||||
Meta: map[string]any{"via": "store-factory", "store_binding": binding, "store_action": c.name},
|
||||
}
|
||||
stampReturnUsage(edge, c.returnUsage)
|
||||
result.Edges = append(result.Edges, edge)
|
||||
continue
|
||||
}
|
||||
edge := &graph.Edge{
|
||||
From: callerID, To: "unresolved::" + c.name,
|
||||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||||
}
|
||||
stampReturnUsage(edge, c.returnUsage)
|
||||
result.Edges = append(result.Edges, edge)
|
||||
}
|
||||
|
||||
// --- Event pub/sub edges ---
|
||||
var pubsubEvents []pubsubEvent
|
||||
var emitterEvents []jsEmitterEvent
|
||||
for _, c := range calls {
|
||||
if !c.isMember || c.expr == nil {
|
||||
continue
|
||||
}
|
||||
if ev, ok := detectJSPubsubCall(c.expr, c.name, src, importPaths, c.line); ok {
|
||||
pubsubEvents = append(pubsubEvents, ev)
|
||||
continue
|
||||
}
|
||||
// Fallback: a bare emitter literal the import gate declined.
|
||||
if em, ok := detectJSEmitterLiteralCall(c.expr, c.name, c.receiver, src, c.line); ok {
|
||||
emitterEvents = append(emitterEvents, em)
|
||||
}
|
||||
}
|
||||
// WebSocket / EventSource real-time client channels.
|
||||
pubsubEvents = append(pubsubEvents, detectJSRealtimeEvents(src)...)
|
||||
emitPubsubEvents(pubsubEvents,
|
||||
func(line int) string { return findEnclosingFunc(funcRanges, line) },
|
||||
filePath, "javascript", result)
|
||||
emitJSEmitterLiteralEvents(emitterEvents,
|
||||
func(line int) string { return findEnclosingFunc(funcRanges, line) },
|
||||
filePath, "javascript", result)
|
||||
|
||||
// SQL function call sites (Supabase/PostgREST .rpc('fn')).
|
||||
emitSQLCallsiteEdges(src, "javascript",
|
||||
func(line int) string { return findEnclosingFunc(funcRanges, line) },
|
||||
filePath, result)
|
||||
|
||||
// --- React Native native-module bridge calls ---
|
||||
rnVars := rnNativeModuleVars(src)
|
||||
for _, c := range calls {
|
||||
if !c.isMember {
|
||||
continue
|
||||
}
|
||||
module := detectRNNativeModule(c.receiver, rnVars)
|
||||
if module == "" {
|
||||
continue
|
||||
}
|
||||
callerID := findEnclosingFunc(funcRanges, c.line)
|
||||
if callerID == "" {
|
||||
continue
|
||||
}
|
||||
edge := &graph.Edge{
|
||||
From: callerID, To: rnNativePlaceholder(module, c.name),
|
||||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||||
Meta: map[string]any{"via": rnNativeVia, "rn_module": module, "rn_method": c.name},
|
||||
}
|
||||
stampReturnUsage(edge, c.returnUsage)
|
||||
result.Edges = append(result.Edges, edge)
|
||||
}
|
||||
|
||||
// Test-runner classification (Mocha / Bun-test / Jest / Vitest /
|
||||
// node:test / Playwright / Cypress). Stamped on the file node so
|
||||
// the indexer's test-edge pass can propagate it to every is_test
|
||||
// function/method without re-reading the file.
|
||||
if runner := DetectJSTSTestRunner(filePath, src, importPaths); runner != "" {
|
||||
if fileNode.Meta == nil {
|
||||
fileNode.Meta = map[string]any{}
|
||||
}
|
||||
fileNode.Meta["test_runner"] = runner
|
||||
}
|
||||
|
||||
captureValueRefCandidates(result, root, filePath, src)
|
||||
captureFnValueCandidates(result, root, filePath, src)
|
||||
captureReduxThunkDispatches(result, root, filePath, src)
|
||||
captureNgRxEffects(result, root, filePath, src)
|
||||
captureObjectRegistryDispatches(result, root, filePath, src)
|
||||
captureRTKQueryEndpoints(result, root, filePath, "javascript", src)
|
||||
capturePiniaStoreCalls(result, root, filePath, src)
|
||||
captureVuexDispatch(result, root, filePath, src)
|
||||
captureExpressInlineHandlers(result, root, filePath, src)
|
||||
captureReactContextRefs(result, root, filePath, src)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Per-match emit helpers -----------------------------------------
|
||||
|
||||
func (e *JavaScriptExtractor) emitFunction(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seenIDs map[string]bool) {
|
||||
name := m.Captures["func.name"].Text
|
||||
def := m.Captures["func.def"]
|
||||
id, ok := disambiguateID(seenIDs, filePath+"::"+name, def.StartLine+1)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
node := &graph.Node{
|
||||
ID: id, Kind: graph.KindFunction, Name: name,
|
||||
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
||||
Language: "javascript", Meta: map[string]any{"signature": fmt.Sprintf("function %s()", name)},
|
||||
}
|
||||
result.Nodes = append(result.Nodes, node)
|
||||
result.Edges = append(result.Edges, &graph.Edge{
|
||||
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
||||
})
|
||||
if body := tsFunctionBody(def.Node); body != nil {
|
||||
StampFunctionMetrics(node, body, "javascript")
|
||||
emitJSXRenderEdges(id, body, src, filePath, result)
|
||||
markFunctionComponent(node.Meta, name, body, def.Node, src, "function")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *JavaScriptExtractor) emitArrow(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, arrowNames map[string]bool, seenIDs map[string]bool) {
|
||||
name := m.Captures["arrow.name"].Text
|
||||
def := m.Captures["arrow.def"]
|
||||
arrowNames[name] = true
|
||||
id, ok := disambiguateID(seenIDs, filePath+"::"+name, def.StartLine+1)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
node := &graph.Node{
|
||||
ID: id, Kind: graph.KindFunction, Name: name,
|
||||
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
||||
Language: "javascript", Meta: map[string]any{"signature": fmt.Sprintf("const %s = () =>", name)},
|
||||
}
|
||||
result.Nodes = append(result.Nodes, node)
|
||||
result.Edges = append(result.Edges, &graph.Edge{
|
||||
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
||||
})
|
||||
// Walk the lexical_declaration down to the arrow_function body. The
|
||||
// query captures `arrow.def` at the lexical-declaration level
|
||||
// (because that's where the binding-name + arrow association lives)
|
||||
// so the body isn't directly captured. JSX child rendering edges
|
||||
// come from inside the arrow's body or expression.
|
||||
if arrow := jsArrowFunctionFromDef(def.Node); arrow != nil {
|
||||
body := arrow.ChildByFieldName("body")
|
||||
if body == nil {
|
||||
body = arrow
|
||||
}
|
||||
StampFunctionMetrics(node, body, "javascript")
|
||||
emitJSXRenderEdges(id, body, src, filePath, result)
|
||||
markFunctionComponent(node.Meta, name, body, def.Node, src, "arrow")
|
||||
}
|
||||
}
|
||||
|
||||
// jsArrowFunctionFromDef descends a lexical_declaration captured at
|
||||
// arrow.def and returns the arrow_function node it wraps. Returns nil
|
||||
// when the structure differs (e.g. the value isn't actually an arrow).
|
||||
func jsArrowFunctionFromDef(def *sitter.Node) *sitter.Node {
|
||||
if def == nil {
|
||||
return nil
|
||||
}
|
||||
for i, _nc := 0, int(def.NamedChildCount()); i < _nc; i++ {
|
||||
c := def.NamedChild(i)
|
||||
if c == nil || c.Type() != "variable_declarator" {
|
||||
continue
|
||||
}
|
||||
v := c.ChildByFieldName("value")
|
||||
if v != nil && v.Type() == "arrow_function" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *JavaScriptExtractor) emitClass(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult) {
|
||||
name := m.Captures["class.name"].Text
|
||||
def := m.Captures["class.def"]
|
||||
id := filePath + "::" + name
|
||||
meta := map[string]any{"type_flavor": "class"}
|
||||
// Class component: a React-family base (Component / PureComponent) or
|
||||
// a Web-Components base (HTMLElement / LitElement) makes this a
|
||||
// component — heritage is the signal, so no JSX walk is needed.
|
||||
if ext := jsxClassExtendsName(def.Node, src); ext != "" {
|
||||
if ui, ck := classHeritageComponentUI(ext, def.Node, src); ui != "" {
|
||||
meta["ui_component"] = ui
|
||||
meta["component_kind"] = ck
|
||||
}
|
||||
}
|
||||
result.Nodes = append(result.Nodes, &graph.Node{
|
||||
ID: id, Kind: graph.KindType, Name: name,
|
||||
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
||||
Language: "javascript",
|
||||
Meta: meta,
|
||||
})
|
||||
result.Edges = append(result.Edges, &graph.Edge{
|
||||
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
||||
})
|
||||
}
|
||||
|
||||
// emitMethod walks up to the enclosing class_declaration and emits the
|
||||
// method with a MemberOf edge. Mirrors the legacy per-class
|
||||
// extractMethods re-run of jsQMethod. Method shorthand inside an object
|
||||
// literal — `const api = { process() {...} }` — also parses as a
|
||||
// `method_definition` (its container is an `object`, not a
|
||||
// `class_declaration`); those are routed to emitObjectLiteralMethod so
|
||||
// they get a real KindFunction node instead of being silently dropped.
|
||||
// The returned (owner, member, id) triple is non-empty only for an
|
||||
// object-literal shorthand and lets Extract register the member so a
|
||||
// later `owner.method()` call resolves to it directly.
|
||||
func (e *JavaScriptExtractor) emitMethod(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seenIDs map[string]bool) (owner, member, id string) {
|
||||
def := m.Captures["method.def"]
|
||||
classNode := findEnclosingJSContainer(def.Node, "class_declaration")
|
||||
if classNode == nil {
|
||||
return e.emitObjectLiteralMethod(m, filePath, fileID, src, result)
|
||||
}
|
||||
nameNode := classNode.ChildByFieldName("name")
|
||||
if nameNode == nil {
|
||||
return "", "", ""
|
||||
}
|
||||
className := nameNode.Content(src)
|
||||
name := m.Captures["method.name"].Text
|
||||
classID := filePath + "::" + className
|
||||
methodID, methodOK := disambiguateID(seenIDs, filePath+"::"+className+"."+name, def.StartLine+1)
|
||||
if !methodOK {
|
||||
return "", "", ""
|
||||
}
|
||||
node := &graph.Node{
|
||||
ID: methodID, Kind: graph.KindMethod, Name: name,
|
||||
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
||||
Language: "javascript",
|
||||
}
|
||||
if body := tsFunctionBody(def.Node); body != nil {
|
||||
StampFunctionMetrics(node, body, "javascript")
|
||||
}
|
||||
result.Nodes = append(result.Nodes, node)
|
||||
result.Edges = append(result.Edges, &graph.Edge{
|
||||
From: methodID, To: classID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: def.StartLine + 1,
|
||||
})
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
// emitClassArrowField emits an arrow-valued class field — `handleClick = () =>
|
||||
// {…}` — as a callable method of its class, so it appears in the call graph
|
||||
// like a regular method rather than vanishing as a plain field.
|
||||
func (e *JavaScriptExtractor) emitClassArrowField(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult) {
|
||||
def := m.Captures["classarrow.def"]
|
||||
classNode := findEnclosingJSContainer(def.Node, "class_declaration")
|
||||
if classNode == nil {
|
||||
return
|
||||
}
|
||||
nameNode := classNode.ChildByFieldName("name")
|
||||
if nameNode == nil {
|
||||
return
|
||||
}
|
||||
className := nameNode.Content(src)
|
||||
name := m.Captures["classarrow.name"].Text
|
||||
classID := filePath + "::" + className
|
||||
methodID := filePath + "::" + className + "." + name
|
||||
node := &graph.Node{
|
||||
ID: methodID, Kind: graph.KindMethod, Name: name,
|
||||
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
||||
Language: "javascript",
|
||||
}
|
||||
if arrow := def.Node.ChildByFieldName("value"); arrow != nil {
|
||||
if body := tsFunctionBody(arrow); body != nil {
|
||||
StampFunctionMetrics(node, body, "javascript")
|
||||
}
|
||||
}
|
||||
result.Nodes = append(result.Nodes, node)
|
||||
result.Edges = append(result.Edges, &graph.Edge{
|
||||
From: methodID, To: classID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: def.StartLine + 1,
|
||||
})
|
||||
}
|
||||
|
||||
// emitObjectLiteralMethod handles method shorthand inside an object
|
||||
// literal — `export const api = { process() {...} }`. tree-sitter
|
||||
// parses `process()` as a `method_definition` whose container is an
|
||||
// `object`, so emitMethod's class walk finds nothing; without this the
|
||||
// shorthand method has no graph node and a call `api.process()` either
|
||||
// resolves to nothing or — worse — to an unrelated free `process`
|
||||
// function elsewhere in the repo. The node is named `<owner>.<method>`,
|
||||
// matching the object-arrow-field convention. Returns (owner, member,
|
||||
// id) when the owner is a top-level `const owner = { ... }` binding;
|
||||
// empty strings for an inline / anonymous object.
|
||||
func (e *JavaScriptExtractor) emitObjectLiteralMethod(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult) (owner, member, id string) {
|
||||
def := m.Captures["method.def"]
|
||||
if def.Node == nil {
|
||||
return "", "", ""
|
||||
}
|
||||
parent := def.Node.Parent()
|
||||
if parent == nil || parent.Type() != "object" {
|
||||
return "", "", ""
|
||||
}
|
||||
member = m.Captures["method.name"].Text
|
||||
if member == "" {
|
||||
return "", "", ""
|
||||
}
|
||||
owner = jsObjectOwnerName(def.Node, src)
|
||||
storeFactory := ""
|
||||
if owner == "" || jsIsStoreOptionKey(owner) {
|
||||
if b, _, ok := jsStoreFactoryBinding(def.Node, src); ok {
|
||||
owner, storeFactory = b, b
|
||||
}
|
||||
}
|
||||
name := member
|
||||
if owner != "" {
|
||||
name = owner + "." + member
|
||||
}
|
||||
id = fmt.Sprintf("%s::%s@%d", filePath, name, def.StartLine+1)
|
||||
node := &graph.Node{
|
||||
ID: id, Kind: graph.KindFunction, Name: name,
|
||||
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
||||
Language: "javascript", Meta: map[string]any{"signature": fmt.Sprintf("%s()", name)},
|
||||
}
|
||||
if storeFactory != "" {
|
||||
node.Meta["store_factory"] = storeFactory
|
||||
node.Meta["store_member"] = member
|
||||
}
|
||||
result.Nodes = append(result.Nodes, node)
|
||||
result.Edges = append(result.Edges, &graph.Edge{
|
||||
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
||||
})
|
||||
if body := tsFunctionBody(def.Node); body != nil {
|
||||
StampFunctionMetrics(node, body, "javascript")
|
||||
emitJSXRenderEdges(id, body, src, filePath, result)
|
||||
}
|
||||
if owner == "" {
|
||||
return "", "", ""
|
||||
}
|
||||
return owner, member, id
|
||||
}
|
||||
|
||||
// emitObjectArrowField handles the `pair → property_identifier →
|
||||
// arrow_function` shape inside an object literal —
|
||||
// `export const api = { health: () => ... }`. Without this, calls
|
||||
// inside the arrow body have no enclosing function for findEnclosingFunc
|
||||
// to attribute them to, so EdgeCalls is silently dropped; and a
|
||||
// `api.health()` call can mis-bind to an unrelated free `health`.
|
||||
func (e *JavaScriptExtractor) emitObjectArrowField(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult) (owner, member, id string) {
|
||||
def := m.Captures["objfn.def"]
|
||||
if def.Node == nil {
|
||||
return "", "", ""
|
||||
}
|
||||
member = m.Captures["objfn.name"].Text
|
||||
if member == "" {
|
||||
return "", "", ""
|
||||
}
|
||||
owner = jsObjectOwnerName(def.Node, src)
|
||||
storeFactory := ""
|
||||
if owner == "" || jsIsStoreOptionKey(owner) {
|
||||
if b, _, ok := jsStoreFactoryBinding(def.Node, src); ok {
|
||||
owner, storeFactory = b, b
|
||||
}
|
||||
}
|
||||
name := member
|
||||
if owner != "" {
|
||||
name = owner + "." + member
|
||||
}
|
||||
id = fmt.Sprintf("%s::%s@%d", filePath, name, def.StartLine+1)
|
||||
node := &graph.Node{
|
||||
ID: id, Kind: graph.KindFunction, Name: name,
|
||||
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
||||
Language: "javascript", Meta: map[string]any{"signature": fmt.Sprintf("%s: () =>", name)},
|
||||
}
|
||||
if storeFactory != "" {
|
||||
node.Meta["store_factory"] = storeFactory
|
||||
node.Meta["store_member"] = member
|
||||
}
|
||||
result.Nodes = append(result.Nodes, node)
|
||||
result.Edges = append(result.Edges, &graph.Edge{
|
||||
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
||||
})
|
||||
if arrow := def.Node.ChildByFieldName("value"); arrow != nil {
|
||||
body := arrow.ChildByFieldName("body")
|
||||
if body == nil {
|
||||
body = arrow
|
||||
}
|
||||
StampFunctionMetrics(node, body, "javascript")
|
||||
emitJSXRenderEdges(id, body, src, filePath, result)
|
||||
markFunctionComponent(node.Meta, name, body, def.Node, src, "arrow")
|
||||
}
|
||||
if owner == "" {
|
||||
return "", "", ""
|
||||
}
|
||||
return owner, member, id
|
||||
}
|
||||
|
||||
// jsObjectOwnerName walks up from an object-literal member node looking
|
||||
// for the nearest enclosing name to qualify the member with — the
|
||||
// binding name of a `const owner = { ... }` declaration or the left
|
||||
// side of an `owner = { ... }` assignment, or the key of an enclosing
|
||||
// `pair` for a nested object. Returns "" when no such name is reachable
|
||||
// (e.g. an inline object passed as an argument) or when a function /
|
||||
// class / program boundary is crossed first.
|
||||
func jsObjectOwnerName(member *sitter.Node, src []byte) string {
|
||||
if member == nil {
|
||||
return ""
|
||||
}
|
||||
for cur := member.Parent(); cur != nil; cur = cur.Parent() {
|
||||
switch cur.Type() {
|
||||
case "variable_declarator":
|
||||
if name := cur.ChildByFieldName("name"); name != nil && name.Type() == "identifier" {
|
||||
return name.Content(src)
|
||||
}
|
||||
return ""
|
||||
case "assignment_expression":
|
||||
if left := cur.ChildByFieldName("left"); left != nil && left.Type() == "identifier" {
|
||||
return left.Content(src)
|
||||
}
|
||||
return ""
|
||||
case "pair":
|
||||
if k := cur.ChildByFieldName("key"); k != nil && k.Type() == "property_identifier" {
|
||||
return k.Content(src)
|
||||
}
|
||||
case "program", "class_body", "function_declaration",
|
||||
"method_definition", "arrow_function", "function_expression":
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *JavaScriptExtractor) emitImport(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult) {
|
||||
importPath := m.Captures["import.path"].Text
|
||||
def := m.Captures["import.def"]
|
||||
line := def.StartLine + 1
|
||||
result.Edges = append(result.Edges, &graph.Edge{
|
||||
From: fileID, To: "unresolved::import::" + importPath,
|
||||
Kind: graph.EdgeImports, FilePath: filePath, Line: line,
|
||||
})
|
||||
// Per-binding edges (`import { a, b as c }`) on top of the module edge.
|
||||
if def.Node != nil {
|
||||
emitJSPerBindingImports(def.Node, importPath, fileID, filePath, src, result)
|
||||
}
|
||||
}
|
||||
|
||||
// emitReExport records the alias-aware re-export edges for an
|
||||
// `export ... from "mod"` statement (the barrel-file forwarding form).
|
||||
func (e *JavaScriptExtractor) emitReExport(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult) {
|
||||
def := m.Captures["reexport.def"]
|
||||
if def.Node == nil {
|
||||
return
|
||||
}
|
||||
importPath := m.Captures["reexport.path"].Text
|
||||
emitJSReExport(def.Node, importPath, fileID, filePath, "javascript", src, result)
|
||||
}
|
||||
|
||||
func (e *JavaScriptExtractor) emitRequire(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult) {
|
||||
if m.Captures["req.name"].Text != "require" {
|
||||
return
|
||||
}
|
||||
reqPath := m.Captures["req.path"].Text
|
||||
line := m.Captures["req.def"].StartLine + 1
|
||||
result.Edges = append(result.Edges, &graph.Edge{
|
||||
From: fileID, To: "unresolved::import::" + reqPath,
|
||||
Kind: graph.EdgeImports, FilePath: filePath, Line: line,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Helpers --------------------------------------------------------
|
||||
|
||||
// findEnclosingJSContainer walks the parent chain of n looking for the
|
||||
// nearest ancestor whose Type() matches t. Returns nil if none.
|
||||
func findEnclosingJSContainer(n *sitter.Node, t string) *sitter.Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
for p := n.Parent(); p != nil; p = p.Parent() {
|
||||
if p.Type() == t {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user