Files
wehub-resource-sync a06f331eb8
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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

948 lines
30 KiB
Go

package languages
import (
"strconv"
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
sitter "github.com/zzet/gortex/internal/parser/tsitter"
)
// emitTSFunctionShape emits the function-shape graph projection for a
// TypeScript / JavaScript function-or-method declaration:
//
// - one KindParam node + EdgeParamOf + EdgeTypedAs per parameter
// - one EdgeReturns per declared return type
// - one KindGenericParam node + EdgeMemberOf per type parameter
//
// declNode is the function_declaration / method_definition / arrow
// function (or its public_field_definition wrapper for class-level
// arrow functions). ownerID is the node ID under which params,
// generics, and return edges should be attributed.
func emitTSFunctionShape(ownerID string, declNode *sitter.Node, src []byte, filePath string, declLine int, result *parser.ExtractionResult) {
if declNode == nil {
return
}
if params := tsParamsList(declNode); params != nil {
emitTSParamNodes(ownerID, params, src, filePath, declLine, result)
}
if rt := tsReturnTypeRaw(declNode, src); rt != "" {
emitTSReturnEdges(ownerID, rt, filePath, declLine, result)
}
emitTSGenericParamNodes(ownerID, declNode, src, filePath, declLine, result)
// Generic-constraint type references (`function f<T extends Foo>`) so a
// type named only as a bound is reachable by find_usages without an LSP.
emitTSConstraintRefs(declNode, ownerID, filePath, src, result)
if body := tsFunctionBody(declNode); body != nil {
emitTSAsyncSpawns(ownerID, body, src, filePath, result)
emitTSFieldAccess(ownerID, body, src, filePath, result)
// Materialise let / const / var / range / catch bindings as
// KindLocal nodes — semantic parity with the Go extractor's
// #77 work. Idempotent on the binding ID (function-relative
// offset), excluded from BM25 search by shouldIndexForSearch,
// and consumed by the resolver's scope-aware bare-name bind
// (#81) for future dataflow / scope-resolution work.
emitTSLocalBindings(ownerID, declLine, body, src, filePath, result)
}
}
// emitTSFieldAccess walks a function body and emits EdgeWrites for
// every assignment whose LHS is a member_expression and EdgeReads
// for every member_expression used as a value (selector use, method
// invocation receiver, expression operand). Mirrors the schema rule
// already implemented in golang.go: LHS-of-assignment writes,
// everything else reads. Nested functions are walked too — TS
// extractors don't always materialise inner closures as separate
// graph nodes, so member accesses anywhere in the enclosing
// function attribute back to it.
func emitTSFieldAccess(ownerID string, body *sitter.Node, src []byte, filePath string, result *parser.ExtractionResult) {
if body == nil {
return
}
type record struct {
field string
op graph.EdgeKind // EdgeReads | EdgeWrites
line int
}
seen := map[string]bool{}
emit := func(r record) {
if r.field == "" {
return
}
key := string(r.op) + "\x00" + r.field
if seen[key] {
return
}
seen[key] = true
result.Edges = append(result.Edges, &graph.Edge{
From: ownerID,
To: "unresolved::*." + r.field,
Kind: r.op,
FilePath: filePath,
Line: r.line,
Origin: graph.OriginASTInferred,
})
}
// Track member expressions that appear on the LHS of an
// assignment so the value-side walker doesn't double-classify
// them as reads. Keyed by (line, field) — sufficient because
// an assignment LHS appears once per line per field.
written := map[string]bool{}
walkTSNodes(body, func(n *sitter.Node) bool {
switch n.Type() {
case "function_declaration", "method_definition":
// Top-level lexical sub-functions own their own
// member access; attributing them to the parent
// would conflate scopes.
return false
case "assignment_expression":
left := n.ChildByFieldName("left")
if left == nil {
return true
}
line := int(n.StartPoint().Row) + 1
if left.Type() == "member_expression" {
prop := left.ChildByFieldName("property")
if prop != nil {
field := prop.Content(src)
emit(record{field: field, op: graph.EdgeWrites, line: line})
written[strconv.Itoa(line)+":"+field] = true
}
}
case "augmented_assignment_expression":
// `x.y += 1` reads + writes; emit both.
left := n.ChildByFieldName("left")
line := int(n.StartPoint().Row) + 1
if left != nil && left.Type() == "member_expression" {
prop := left.ChildByFieldName("property")
if prop != nil {
field := prop.Content(src)
emit(record{field: field, op: graph.EdgeWrites, line: line})
emit(record{field: field, op: graph.EdgeReads, line: line})
written[strconv.Itoa(line)+":"+field] = true
}
}
case "update_expression":
// `x.y++` / `x.y--` write.
arg := n.ChildByFieldName("argument")
line := int(n.StartPoint().Row) + 1
if arg != nil && arg.Type() == "member_expression" {
prop := arg.ChildByFieldName("property")
if prop != nil {
field := prop.Content(src)
emit(record{field: field, op: graph.EdgeWrites, line: line})
written[strconv.Itoa(line)+":"+field] = true
}
}
}
return true
})
walkTSNodes(body, func(n *sitter.Node) bool {
switch n.Type() {
case "function_declaration", "method_definition":
return false
case "member_expression":
// Skip when this expression is the LHS of an
// assignment we already classified.
line := int(n.StartPoint().Row) + 1
prop := n.ChildByFieldName("property")
if prop == nil {
return true
}
field := prop.Content(src)
if written[strconv.Itoa(line)+":"+field] {
return true
}
// Skip method-call receivers — those become
// EdgeCalls via the existing call-emit pass and
// shouldn't double-count as reads.
if parent := n.Parent(); parent != nil && parent.Type() == "call_expression" {
if fn := parent.ChildByFieldName("function"); fn != nil && fn.Equal(n) {
return true
}
}
emit(record{field: field, op: graph.EdgeReads, line: line})
}
return true
})
}
// emitTSAsyncSpawns walks a function body and emits EdgeSpawns for
// every awaited call (`await foo()`, `await this.svc.load()`) and
// every Promise constructor / Promise.all / Promise.then dispatch.
// Mode is "async" for await_expression, "promise" for Promise.x.
//
// Nested function/arrow bodies are skipped — their awaits belong to
// the inner scope; the owning emitFunction/emitArrow pass picks
// them up directly.
func emitTSAsyncSpawns(ownerID string, body *sitter.Node, src []byte, filePath string, result *parser.ExtractionResult) {
if body == nil {
return
}
seen := map[string]bool{}
emit := func(target, mode string, line int) {
if target == "" {
return
}
key := mode + "\x00" + target
if seen[key] {
return
}
seen[key] = true
result.Edges = append(result.Edges, &graph.Edge{
From: ownerID,
To: "unresolved::" + target,
Kind: graph.EdgeSpawns,
FilePath: filePath,
Line: line,
Origin: graph.OriginASTInferred,
Meta: map[string]any{
"mode": mode,
},
})
}
walkTSNodes(body, func(n *sitter.Node) bool {
switch n.Type() {
case "function_declaration", "function_expression", "arrow_function",
"method_definition", "generator_function", "generator_function_declaration":
// Don't descend into nested function bodies.
return false
case "await_expression":
if call := tsFindCallExpression(n); call != nil {
if name := tsCallTargetName(call, src); name != "" {
emit(name, "async", int(n.StartPoint().Row)+1)
}
}
return true
case "call_expression":
fn := n.ChildByFieldName("function")
if fn == nil {
return true
}
// Promise.all / Promise.allSettled / Promise.race —
// walk the first argument's array elements (each is a
// call_expression we should attribute to). We only
// emit a coarse "Promise.all" target so traversals
// can highlight the dispatch site even when arg
// resolution is too dynamic to track.
if fn.Type() == "member_expression" {
obj := fn.ChildByFieldName("object")
prop := fn.ChildByFieldName("property")
if obj != nil && prop != nil {
if obj.Content(src) == "Promise" {
emit("Promise."+prop.Content(src), "promise", int(n.StartPoint().Row)+1)
}
}
}
}
return true
})
}
// walkTSNodes is a TS analogue to walkGoNodes: pre-order, returning
// false from visit skips the subtree.
func walkTSNodes(n *sitter.Node, visit func(*sitter.Node) bool) {
if n == nil {
return
}
if !visit(n) {
return
}
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
walkTSNodes(n.NamedChild(i), visit)
}
}
func tsFindCallExpression(n *sitter.Node) *sitter.Node {
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
c := n.NamedChild(i)
if c == nil {
continue
}
if c.Type() == "call_expression" {
return c
}
}
return nil
}
// tsCallTargetName extracts the textual function name of a TS call
// expression. Returns "" when the call is too dynamic (e.g. an IIFE
// or a higher-order call result).
func tsCallTargetName(call *sitter.Node, src []byte) string {
fn := call.ChildByFieldName("function")
if fn == nil {
return ""
}
switch fn.Type() {
case "identifier":
return fn.Content(src)
case "member_expression":
// e.g. svc.load, this.repo.find — return the property name
// so the resolver can land it via the EdgeReads/EdgeWrites
// receiver-type fallback.
if prop := fn.ChildByFieldName("property"); prop != nil {
return prop.Content(src)
}
}
return ""
}
// tsReturnTypeRaw returns the verbatim source of a function/method's
// return type annotation, without the upstream normalization that
// strips generics and primitives. Returns "" when there's no
// annotation.
func tsReturnTypeRaw(decl *sitter.Node, src []byte) string {
if decl == nil {
return ""
}
for i, _nc := 0, int(decl.NamedChildCount()); i < _nc; i++ {
c := decl.NamedChild(i)
if c == nil || c.Type() != "type_annotation" {
continue
}
if c.NamedChildCount() > 0 {
tn := c.NamedChild(0)
if tn != nil {
return strings.TrimSpace(tn.Content(src))
}
}
}
return ""
}
// tsParamName returns the parameter's bound identifier, descending
// into rest_pattern / object_pattern so destructured + variadic
// parameters still surface a name. Returns "" when no simple
// identifier is available (deep destructuring like `{a: {b}}` —
// not a single binding so we drop them).
func tsParamName(p *sitter.Node, src []byte) string {
if p == nil {
return ""
}
pattern := p.ChildByFieldName("pattern")
if pattern != nil {
switch pattern.Type() {
case "identifier":
return pattern.Content(src)
case "rest_pattern":
// rest_pattern wraps an identifier child.
for i, _nc := 0, int(pattern.NamedChildCount()); i < _nc; i++ {
c := pattern.NamedChild(i)
if c != nil && c.Type() == "identifier" {
return c.Content(src)
}
}
}
}
// Fallback: scan named children for an identifier (older grammar
// shapes don't always set the pattern field).
for i, _nc := 0, int(p.NamedChildCount()); i < _nc; i++ {
c := p.NamedChild(i)
if c == nil {
continue
}
if c.Type() == "identifier" {
return c.Content(src)
}
if c.Type() == "rest_pattern" {
for j, _nc := 0, int(c.NamedChildCount()); j < _nc; j++ {
cc := c.NamedChild(j)
if cc != nil && cc.Type() == "identifier" {
return cc.Content(src)
}
}
}
}
return ""
}
// tsParamTypeRaw returns the verbatim source of a parameter's type
// annotation, without the upstream normalization that strips generics
// and primitives.
func tsParamTypeRaw(p *sitter.Node, src []byte) string {
if p == nil {
return ""
}
ta := p.ChildByFieldName("type")
if ta == nil {
for i, _nc := 0, int(p.NamedChildCount()); i < _nc; i++ {
c := p.NamedChild(i)
if c != nil && c.Type() == "type_annotation" {
ta = c
break
}
}
}
if ta == nil {
return ""
}
for i, _nc := 0, int(ta.NamedChildCount()); i < _nc; i++ {
c := ta.NamedChild(i)
if c == nil {
continue
}
return strings.TrimSpace(c.Content(src))
}
return ""
}
// tsParamsList returns the formal parameter list child of a TS / JS
// function-shaped node. Function/method/arrow nodes use field name
// "parameters". Returns nil when missing.
func tsParamsList(decl *sitter.Node) *sitter.Node {
if decl == nil {
return nil
}
if p := decl.ChildByFieldName("parameters"); p != nil {
return p
}
// Some grammar shapes use a formal_parameters child directly
// without a field name.
for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ {
c := decl.Child(i)
if c != nil && (c.Type() == "formal_parameters" || c.Type() == "call_signature") {
return c
}
}
return nil
}
// emitTSParamNodes walks formal parameters and emits one KindParam
// node per name plus EdgeParamOf and (when the type annotation is
// present) EdgeTypedAs.
func emitTSParamNodes(ownerID string, params *sitter.Node, src []byte, filePath string, declLine int, result *parser.ExtractionResult) {
pos := 0
for i, _nc := 0, int(params.NamedChildCount()); i < _nc; i++ {
decl := params.NamedChild(i)
if decl == nil {
continue
}
t := decl.Type()
switch t {
case "required_parameter", "optional_parameter":
// fall through
default:
continue
}
isVariadic := false
// `...rest: T` is parsed as required_parameter pattern: rest_pattern.
if pat := decl.ChildByFieldName("pattern"); pat != nil && pat.Type() == "rest_pattern" {
isVariadic = true
}
name := tsParamName(decl, src)
if name == "" || name == "_" {
continue
}
typeName := tsParamTypeRaw(decl, src)
paramID := tsParamNodeID(ownerID, name, pos)
meta := map[string]any{"position": pos}
if isVariadic {
meta["variadic"] = true
}
if typeName != "" {
meta["type"] = typeName
}
startLine := int(decl.StartPoint().Row) + 1
if startLine == 0 {
startLine = declLine
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: paramID,
Kind: graph.KindParam,
Name: name,
FilePath: filePath,
StartLine: startLine,
EndLine: int(decl.EndPoint().Row) + 1,
Language: "typescript",
Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: paramID,
To: ownerID,
Kind: graph.EdgeParamOf,
FilePath: filePath,
Line: startLine,
Origin: graph.OriginASTResolved,
})
for _, ref := range tsTypeRefs(typeName) {
result.Edges = append(result.Edges, &graph.Edge{
From: paramID,
To: "unresolved::" + ref,
Kind: graph.EdgeTypedAs,
FilePath: filePath,
Line: startLine,
Origin: graph.OriginASTInferred,
})
}
pos++
}
}
// emitTSReturnEdges parses the source of a return-type annotation
// and emits an EdgeReturns per (top-level) type. Union types
// (`A | B`) emit one edge per branch so traversals can find every
// possible runtime return type.
func emitTSReturnEdges(ownerID, returnText, filePath string, line int, result *parser.ExtractionResult) {
for i, t := range tsTypeRefs(returnText) {
result.Edges = append(result.Edges, &graph.Edge{
From: ownerID,
To: "unresolved::" + t,
Kind: graph.EdgeReturns,
FilePath: filePath,
Line: line,
Origin: graph.OriginASTInferred,
Meta: map[string]any{
"position": i,
},
})
}
}
// emitTSTypeUseEdges parses a variable / const / field type annotation
// and emits one EdgeTypedAs per top-level named type to
// unresolved::<type>, so a type used only in annotation position is a
// first-class cross-file reference the name-based resolver can land
// without an LSP. Union / intersection branches each emit an edge,
// mirroring emitTSReturnEdges; primitives are skipped.
func emitTSTypeUseEdges(ownerID, typeText, filePath string, line int, result *parser.ExtractionResult) {
for _, t := range tsTypeRefs(typeText) {
result.Edges = append(result.Edges, &graph.Edge{
From: ownerID,
To: "unresolved::" + t,
Kind: graph.EdgeTypedAs,
FilePath: filePath,
Line: line,
Origin: graph.OriginASTInferred,
})
}
}
// tsBuiltinGenerics are container / utility generics whose own name is not
// a useful cross-file reference (they have no repo definition) but whose
// type arguments are — so tsTypeRefs recurses into them without emitting
// the wrapper itself. A user-defined wrapper (NonDeleted<Foo>) is NOT in
// this set, so both NonDeleted and Foo surface as references.
var tsBuiltinGenerics = map[string]bool{
"Promise": true, "PromiseLike": true, "Awaited": true,
"Array": true, "ReadonlyArray": true,
"Map": true, "ReadonlyMap": true, "WeakMap": true,
"Set": true, "ReadonlySet": true, "WeakSet": true,
"Record": true, "Readonly": true, "Partial": true, "Required": true,
"Pick": true, "Omit": true, "Exclude": true, "Extract": true,
"NonNullable": true, "Parameters": true, "ReturnType": true,
"InstanceType": true, "Iterable": true, "IterableIterator": true,
"Iterator": true, "Generator": true,
}
// tsTypeRefs returns the distinct named type references in a TypeScript type
// annotation, decomposing unions / intersections, `readonly`, arrays,
// parentheses and generic type arguments. A type used only as a type
// argument — `Map<string, Foo>`, `NonDeleted<Foo>`, `readonly Foo[]` —
// surfaces as a reference; primitives and container/utility generics are
// dropped (but recursed into). This is what lets find_usages land a type
// that never appears bare, only wrapped.
func tsTypeRefs(typeText string) []string {
var out []string
seen := map[string]bool{}
var walk func(t string)
walk = func(t string) {
t = strings.TrimSpace(t)
t = strings.TrimPrefix(t, "readonly ")
t = strings.TrimSpace(t)
for strings.HasSuffix(t, "[]") {
t = strings.TrimSpace(strings.TrimSuffix(t, "[]"))
}
for strings.HasPrefix(t, "(") && strings.HasSuffix(t, ")") {
t = strings.TrimSpace(t[1 : len(t)-1])
}
if t == "" {
return
}
// Indexed-access (lookup) type `T[K]` — distinct from the array
// suffix `T[]` already stripped above. The object type T is a real
// reference (`ExcalidrawElement["type"]` references ExcalidrawElement);
// a non-literal key (`T[Key]`) is a type reference too, a string /
// number literal key is dropped by addTSRef. Split at the matching
// top-level `[` so the wrapped object type and the key both surface.
if obj, key, ok := splitTSLookupType(t); ok {
walk(obj)
walk(key)
return
}
if parts := splitTSUnionType(t); len(parts) > 1 {
for _, p := range parts {
walk(p)
}
return
}
if i := strings.IndexByte(t, '<'); i >= 0 && strings.HasSuffix(t, ">") {
addTSRef(strings.TrimSpace(t[:i]), &out, seen)
for _, arg := range splitTSTypeArgs(t[i+1 : len(t)-1]) {
walk(arg)
}
return
}
addTSRef(t, &out, seen)
}
walk(typeText)
return out
}
// addTSRef appends a bare named type to out (deduped) after stripping
// keyof/typeof prefixes and module qualifiers, skipping primitives,
// container/utility generics, and anything that is not a plain identifier
// (string-literal types, object-type literals, mapped types).
func addTSRef(name string, out *[]string, seen map[string]bool) {
name = strings.TrimSpace(name)
name = strings.TrimPrefix(name, "keyof ")
name = strings.TrimPrefix(name, "typeof ")
name = strings.TrimSpace(name)
if i := strings.LastIndex(name, "."); i >= 0 {
name = name[i+1:]
}
if name == "" || isTSPrimitive(name) || tsBuiltinGenerics[name] || !isTSTypeName(name) || seen[name] {
return
}
seen[name] = true
*out = append(*out, name)
}
// isTSTypeName reports whether s is a plain (ASCII) type identifier, so a
// string-literal type ("foo"), numeric literal, or object-type residue
// never becomes a bogus unresolved target.
func isTSTypeName(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
ok := c == '_' || c == '$' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
if i > 0 {
ok = ok || (c >= '0' && c <= '9')
}
if !ok {
return false
}
}
return true
}
// splitTSTypeArgs splits a generic argument list at top-level commas,
// respecting nested <>, (), {}, [].
func splitTSTypeArgs(s string) []string {
var parts []string
depth := 0
cur := strings.Builder{}
for i := 0; i < len(s); i++ {
c := s[i]
switch c {
case '<', '(', '{', '[':
depth++
case '>', ')', '}', ']':
if depth > 0 {
depth--
}
case ',':
if depth == 0 {
parts = append(parts, cur.String())
cur.Reset()
continue
}
}
cur.WriteByte(c)
}
if last := strings.TrimSpace(cur.String()); last != "" {
parts = append(parts, last)
}
return parts
}
// splitTSLookupType decomposes an indexed-access (lookup) type `T[K]` into
// its object type T and key K. It only fires when the trailing `[…]` is a
// non-empty index whose opening `[` is at top-level (not inside a generic
// argument or nested bracket) — the array suffix `T[]` is empty and is
// stripped by the caller before this runs, so it never matches here.
// Returns (object, key, true) on a lookup type, ("", "", false) otherwise.
func splitTSLookupType(t string) (string, string, bool) {
t = strings.TrimSpace(t)
if len(t) < 3 || !strings.HasSuffix(t, "]") {
return "", "", false
}
// Find the matching `[` for the trailing `]`, respecting nesting.
depth := 0
open := -1
for i := len(t) - 1; i >= 0; i-- {
switch t[i] {
case ']', ')', '}', '>':
depth++
case '[', '(', '{', '<':
depth--
if depth == 0 {
open = i
goto found
}
}
}
found:
if open <= 0 {
return "", "", false
}
obj := strings.TrimSpace(t[:open])
key := strings.TrimSpace(t[open+1 : len(t)-1])
if obj == "" || key == "" {
return "", "", false
}
return obj, key, true
}
// emitTSGenericParamNodes turns a TS function/class declaration's
// type_parameters into KindGenericParam nodes plus EdgeMemberOf back
// to the owner. Constraints and defaults are stored as meta.bound /
// meta.default for downstream queries.
func emitTSGenericParamNodes(ownerID string, decl *sitter.Node, src []byte, filePath string, line int, result *parser.ExtractionResult) {
tparams := tsTypeParams(decl, src)
if len(tparams) == 0 {
return
}
for _, tp := range tparams {
name := tp["name"]
if name == "" {
continue
}
gpID := ownerID + "#tparam:" + name
meta := map[string]any{}
if b := tp["bound"]; b != "" {
meta["bound"] = b
}
if d := tp["default"]; d != "" {
meta["default"] = d
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: gpID,
Kind: graph.KindGenericParam,
Name: name,
FilePath: filePath,
StartLine: line,
EndLine: line,
Language: "typescript",
Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: gpID,
To: ownerID,
Kind: graph.EdgeMemberOf,
FilePath: filePath,
Line: line,
Origin: graph.OriginASTResolved,
})
}
}
// tsParamNodeID builds the unique-per-owner ID for a parameter
// node. Mirrors goParamNodeID.
func tsParamNodeID(ownerID, name string, pos int) string {
return ownerID + "#param:" + name + "@" + strconv.Itoa(pos)
}
// canonicalizeTSTypeRef strips wrapping noise (Promise<X>, Array<X>,
// X[], readonly X) so the resolver can match the declared type to a
// type node defined in the workspace.
func canonicalizeTSTypeRef(t string) string {
t = strings.TrimSpace(t)
if t == "" {
return ""
}
// Strip leading colon if the caller didn't already.
t = strings.TrimPrefix(t, ":")
t = strings.TrimSpace(t)
// Strip readonly.
t = strings.TrimPrefix(t, "readonly ")
// Recurse-strip generic wrappers we know are pass-through:
// Promise<T>, Array<T>, ReadonlyArray<T>, Awaited<T>.
for _, wrapper := range []string{"Promise", "Array", "ReadonlyArray", "Awaited"} {
if strings.HasPrefix(t, wrapper+"<") && strings.HasSuffix(t, ">") {
inner := t[len(wrapper)+1 : len(t)-1]
return canonicalizeTSTypeRef(inner)
}
}
// Strip array suffix.
for strings.HasSuffix(t, "[]") {
t = strings.TrimSuffix(t, "[]")
t = strings.TrimSpace(t)
}
// Strip surrounding parens.
for strings.HasPrefix(t, "(") && strings.HasSuffix(t, ")") {
t = strings.TrimSpace(t[1 : len(t)-1])
}
return t
}
// maxTSUnionMembers caps how many top-level union/intersection branches
// splitTSUnionType returns. The TypeScript LSP and type-printer can
// synthesise pathological type-texts — a 200-member string-literal union,
// a distributive conditional type expanded over a large enum — where
// emitting one EdgeReturns per branch produces dozens of noise edges to
// ad-hoc literal types no traversal benefits from. Past this many
// top-level branches the type is treated as opaque overflow:
// splitTSUnionType returns nil so the caller emits no per-branch edges.
// 16 comfortably covers real discriminated unions, which rarely exceed a
// handful of variants.
const maxTSUnionMembers = 16
// splitTSUnionType splits a TypeScript type string at top-level `|`
// (union) and `&` (intersection) boundaries, respecting <…>, (…), {…},
// […] nesting. A union member is a type the value may be at runtime; an
// intersection member is a type the value simultaneously satisfies —
// both are useful EdgeReturns targets, so both delimiters split (without
// splitting, an intersection like `A & B` would mangle into a single
// bogus `A & B` reference). Returns nil when the branch count exceeds
// maxTSUnionMembers (the overflow guard) so a synthesised literal blob
// never floods the graph.
func splitTSUnionType(t string) []string {
t = strings.TrimSpace(t)
if t == "" {
return nil
}
t = strings.TrimPrefix(t, ":")
t = strings.TrimSpace(t)
depth := 0
parts := []string{}
cur := strings.Builder{}
flush := func() {
if s := strings.TrimSpace(cur.String()); s != "" {
parts = append(parts, s)
}
cur.Reset()
}
for i := 0; i < len(t); i++ {
c := t[i]
switch c {
case '<', '(', '{', '[':
depth++
case '>', ')', '}', ']':
// Guard against underflow on `=>` (arrow function types) and
// other stray closers — an unbalanced `>` must not drop depth
// below zero, or a later top-level `|` would never split.
if depth > 0 {
depth--
}
case '|', '&':
if depth == 0 {
flush()
if len(parts) > maxTSUnionMembers {
return nil
}
continue
}
}
cur.WriteByte(c)
}
flush()
if len(parts) > maxTSUnionMembers {
return nil
}
return parts
}
// isTSPrimitive returns true when t names a TypeScript builtin / DOM
// primitive that doesn't need an EdgeReturns target — emitting these
// would just clutter the graph with unresolved::string / unresolved::
// number edges that never land.
func isTSPrimitive(t string) bool {
switch t {
case "", "void", "any", "unknown", "never", "null", "undefined",
"string", "number", "boolean", "bigint", "symbol", "object",
"this", "true", "false":
return true
}
return false
}
// emitTSCastTypeRefs walks a parsed TS/TSX file and emits a cast
// type-reference edge for every type assertion:
//
// - as_expression — `x as Foo`, `x as Foo[]`, `x as NonDeleted<Foo>`
// - satisfies_expression — `x satisfies Foo`
// - type_assertion — `<Foo>x` (plain .ts only; the TSX grammar
// never produces this node because `<Foo>` is a JSX opening element)
//
// Each names the asserted type(s); the edge is EdgeTypedAs to
// unresolved::<name> with use_kind:"cast", attributed to the enclosing
// function (fallback: the file node). Decomposition (unions, generics,
// arrays, primitive/container dropping) is delegated to tsTypeRefs.
// De-duplicated per (owner, name, line) so an
// expression that a future query might also match elsewhere can't
// double-emit.
func emitTSCastTypeRefs(root *sitter.Node, src []byte, filePath, fileID string, funcRanges []funcRange, result *parser.ExtractionResult) {
if root == nil {
return
}
seen := map[string]bool{}
emit := func(typeText string, line int) {
typeText = strings.TrimSpace(typeText)
if typeText == "" {
return
}
ownerID := findEnclosingFunc(funcRanges, line)
if ownerID == "" {
ownerID = fileID
}
for _, name := range tsTypeRefs(typeText) {
key := ownerID + "\x00" + name + "\x00" + strconv.Itoa(line)
if seen[key] {
continue
}
seen[key] = true
result.Edges = append(result.Edges, &graph.Edge{
From: ownerID,
To: "unresolved::" + name,
Kind: graph.EdgeTypedAs,
FilePath: filePath,
Line: line,
Origin: graph.OriginASTInferred,
Meta: map[string]any{"use_kind": "cast"},
})
}
}
walkTSNodes(root, func(n *sitter.Node) bool {
switch n.Type() {
case "as_expression", "satisfies_expression":
// Shape: (as_expression <value> <type>) — the asserted type
// is the last named child (the first is the value expression).
if tn := tsCastTypeNode(n); tn != nil {
emit(tn.Content(src), int(n.StartPoint().Row)+1)
}
case "type_assertion":
// Shape: (type_assertion (type_arguments <type>) <value>) —
// the angle-bracket `<Foo>x` form, plain .ts only. The
// type_arguments text carries the surrounding `<…>`; the
// inner type_identifier(s) are the real reference, so trim
// the brackets before decomposing.
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
c := n.NamedChild(i)
if c != nil && c.Type() == "type_arguments" {
inner := strings.TrimSpace(c.Content(src))
inner = strings.TrimPrefix(inner, "<")
inner = strings.TrimSuffix(inner, ">")
emit(inner, int(n.StartPoint().Row)+1)
break
}
}
}
return true
})
}
// tsCastTypeNode returns the asserted-type node of an as_expression or
// satisfies_expression — the last named child, since the value
// expression precedes it. Returns nil for a malformed node.
func tsCastTypeNode(n *sitter.Node) *sitter.Node {
count := int(n.NamedChildCount())
if count == 0 {
return nil
}
return n.NamedChild(count - 1)
}