diff --git a/internal/extractors/tsextractor/ts.go b/internal/extractors/tsextractor/ts.go index 21c2b85..c5f1729 100644 --- a/internal/extractors/tsextractor/ts.go +++ b/internal/extractors/tsextractor/ts.go @@ -139,6 +139,17 @@ func (e *TSExtractor) Extract(ctx context.Context, repoPath string, files []stri return allFacts, nil } +// extractCtx bundles the per-file state threaded through declaration extraction +// so symbols can be enriched with React/Next.js semantic classification. +type extractCtx struct { + src []byte + relFile string + dir string + isTSX bool + isNextJS bool + importMap map[string]string +} + func (e *TSExtractor) extractFile(src []byte, relFile string, isNextJS bool, aliases map[string]string) []facts.Fact { var result []facts.Fact @@ -151,8 +162,9 @@ func (e *TSExtractor) extractFile(src []byte, relFile string, isNextJS bool, ali // Hand-written fetch / makeRequest API calls are also client-role routes. result = append(result, extractHTTPClientFacts(src, relFile)...) + isTSX := strings.HasSuffix(relFile, ".tsx") || strings.HasSuffix(relFile, ".jsx") lang := typescript.LanguageTypescript() - if strings.HasSuffix(relFile, ".tsx") { + if isTSX { lang = typescript.LanguageTSX() } @@ -167,8 +179,32 @@ func (e *TSExtractor) extractFile(src []byte, relFile string, isNextJS bool, ali // Extract from the tree result = append(result, e.extractImports(root, src, relFile, aliases)...) - importMap := buildImportSymbols(root, src, relFile, aliases) - result = append(result, e.extractDeclarations(root, src, relFile, importMap)...) + + ctx := &extractCtx{ + src: src, + relFile: relFile, + dir: filepath.Dir(relFile), + isTSX: isTSX, + isNextJS: isNextJS, + importMap: buildImportSymbols(root, src, relFile, aliases), + } + decls := e.extractDeclarations(root, ctx) + + // A declaration may be exported via a separate `export { A, B }` clause or + // `export default Name` statement rather than an inline `export` keyword. + // Mark the corresponding symbols as exported. + if exported := collectExportedLocalNames(root, src); len(exported) > 0 { + for i := range decls { + if decls[i].Kind != facts.KindSymbol { + continue + } + local := decls[i].Name[strings.LastIndexByte(decls[i].Name, '.')+1:] + if exported[local] { + decls[i].Props["exported"] = true + } + } + } + result = append(result, decls...) // Detect Next.js routes if isNextJS { @@ -224,229 +260,273 @@ func (e *TSExtractor) extractImports(root *sitter.Node, src []byte, relFile stri return result } -func (e *TSExtractor) extractDeclarations(root *sitter.Node, src []byte, relFile string, importMap map[string]string) []facts.Fact { +func (e *TSExtractor) extractDeclarations(root *sitter.Node, ctx *extractCtx) []facts.Fact { var result []facts.Fact - dir := filepath.Dir(relFile) - for i := range root.ChildCount() { - child := root.Child(i) - ff := e.extractNode(child, src, relFile, dir, false, importMap) - result = append(result, ff...) + result = append(result, e.extractNode(root.Child(i), ctx, false, "")...) } - return result } -func (e *TSExtractor) extractNode(node *sitter.Node, src []byte, relFile, dir string, isExported bool, importMap map[string]string) []facts.Fact { +// extractNode emits facts for a single declaration node. fallbackName supplies a +// name for anonymous default-exported declarations (e.g. `export default function +// () {}`), derived from the file name; it is ignored when the declaration has its +// own name. +func (e *TSExtractor) extractNode(node *sitter.Node, ctx *extractCtx, isExported bool, fallbackName string) []facts.Fact { var result []facts.Fact + src, dir, relFile := ctx.src, ctx.dir, ctx.relFile switch node.Kind() { case "export_statement": - // Process the declaration inside the export - decl := findChildByKind(node, "function_declaration") - if decl == nil { - decl = findChildByKind(node, "class_declaration") + isDefault := hasChildKind(node, "default") + fb := "" + if isDefault { + fb = fileSymbolName(relFile) } - if decl == nil { - decl = findChildByKind(node, "interface_declaration") + // Named/inline declaration inside the export. + if decl := firstDeclChild(node); decl != nil { + return e.extractNode(decl, ctx, true, fb) } - if decl == nil { - decl = findChildByKind(node, "type_alias_declaration") - } - if decl == nil { - decl = findChildByKind(node, "lexical_declaration") - } - if decl != nil { - return e.extractNode(decl, src, relFile, dir, true, importMap) + // Anonymous default export of a value: name it after the file. + if isDefault { + for _, k := range []string{"function_expression", "generator_function", "class", "arrow_function", "call_expression"} { + if c := findChildByKind(node, k); c != nil { + return e.extractNode(c, ctx, true, fb) + } + } } - case "function_declaration": + case "function_declaration", "function_expression", "generator_function_declaration", "generator_function": name := findChildByKind(node, "identifier") + symbolName := fallbackName if name != nil { - symbolName := nodeText(name, src) - rels := []facts.Relation{{Kind: facts.RelDeclares, Target: dir}} - rels = append(rels, collectCalls(node, src, dir, "", importMap)...) - result = append(result, facts.Fact{ - Kind: facts.KindSymbol, - Name: dir + "." + symbolName, - File: relFile, - Line: int(node.StartPosition().Row) + 1, - Props: map[string]any{ - "symbol_kind": facts.SymbolFunc, - "exported": isExported, - "language": "typescript", - }, - Relations: rels, - }) + symbolName = nodeText(name, src) + } + if symbolName == "" { + break + } + result = append(result, e.funcSymbol(node, node, ctx, symbolName, isExported)) + + case "arrow_function": + if fallbackName != "" { + result = append(result, e.funcSymbol(node, node, ctx, fallbackName, isExported)) + } + + case "call_expression": + // Reached for `export default memo(...)` / `forwardRef(...)`. + if fallbackName != "" { + result = append(result, e.funcSymbol(node, node, ctx, fallbackName, isExported)) } - case "class_declaration": + case "class_declaration", "abstract_class_declaration", "class": name := findChildByKind(node, "type_identifier") + symbolName := fallbackName if name != nil { - symbolName := nodeText(name, src) - f := facts.Fact{ - Kind: facts.KindSymbol, - Name: dir + "." + symbolName, - File: relFile, - Line: int(node.StartPosition().Row) + 1, - Props: map[string]any{ - "symbol_kind": facts.SymbolClass, - "exported": isExported, - "language": "typescript", - }, - Relations: []facts.Relation{ - {Kind: facts.RelDeclares, Target: dir}, - }, - } + symbolName = nodeText(name, src) + } + if symbolName == "" { + break + } + f := facts.Fact{ + Kind: facts.KindSymbol, + Name: dir + "." + symbolName, + File: relFile, + Line: int(node.StartPosition().Row) + 1, + Props: map[string]any{ + "symbol_kind": facts.SymbolClass, + "exported": isExported, + "language": "typescript", + }, + Relations: []facts.Relation{ + {Kind: facts.RelDeclares, Target: dir}, + }, + } - // Check for implements clause (nested under class_heritage) - for j := range node.ChildCount() { - c := node.Child(j) - if c.Kind() == "class_heritage" { - for k := range c.ChildCount() { - heritage := c.Child(k) - if heritage.Kind() == "implements_clause" { - for l := range heritage.ChildCount() { - t := heritage.Child(l) - if t.Kind() == "type_identifier" { - f.Relations = append(f.Relations, facts.Relation{ - Kind: facts.RelImplements, - Target: nodeText(t, src), - }) - } + // Check for implements clause (nested under class_heritage) + for j := range node.ChildCount() { + c := node.Child(j) + if c.Kind() == "class_heritage" { + for k := range c.ChildCount() { + heritage := c.Child(k) + if heritage.Kind() == "implements_clause" { + for l := range heritage.ChildCount() { + t := heritage.Child(l) + if t.Kind() == "type_identifier" { + f.Relations = append(f.Relations, facts.Relation{ + Kind: facts.RelImplements, + Target: nodeText(t, src), + }) } } } } } + } - result = append(result, f) + classBody := findChildByKind(node, "class_body") + classifySymbol(&f, symbolName, classBody, ctx, facts.SymbolClass) + result = append(result, f) - // Extract class methods - classBody := findChildByKind(node, "class_body") - if classBody != nil { - for j := range classBody.ChildCount() { - member := classBody.Child(j) - if member.Kind() != "method_definition" && member.Kind() != "public_field_definition" { - continue - } - methodName := findChildByKind(member, "property_identifier") - if methodName == nil { - methodName = findChildByKind(member, "identifier") - } - if methodName == nil { - continue - } - mName := nodeText(methodName, src) - if strings.HasPrefix(mName, "#") || mName == "constructor" { - continue - } - isPrivate := false - for k := range member.ChildCount() { - c := member.Child(k) - if c.Kind() == "accessibility_modifier" && nodeText(c, src) == "private" { - isPrivate = true - break - } + // Extract class methods + if classBody != nil { + for j := range classBody.ChildCount() { + member := classBody.Child(j) + if member.Kind() != "method_definition" && member.Kind() != "public_field_definition" { + continue + } + methodName := findChildByKind(member, "property_identifier") + if methodName == nil { + methodName = findChildByKind(member, "identifier") + } + if methodName == nil { + continue + } + mName := nodeText(methodName, src) + if strings.HasPrefix(mName, "#") || mName == "constructor" { + continue + } + isPrivate := false + for k := range member.ChildCount() { + c := member.Child(k) + if c.Kind() == "accessibility_modifier" && nodeText(c, src) == "private" { + isPrivate = true + break } - mRels := []facts.Relation{{Kind: facts.RelDeclares, Target: dir}} - mRels = append(mRels, collectCalls(member, src, dir, symbolName, importMap)...) - result = append(result, facts.Fact{ - Kind: facts.KindSymbol, - Name: dir + "." + symbolName + "." + mName, - File: relFile, - Line: int(member.StartPosition().Row) + 1, - Props: map[string]any{ - "symbol_kind": facts.SymbolMethod, - "exported": isExported && !isPrivate, - "language": "typescript", - "receiver": symbolName, - }, - Relations: mRels, - }) } + mRels := []facts.Relation{{Kind: facts.RelDeclares, Target: dir}} + mRels = append(mRels, collectCalls(member, src, dir, symbolName, ctx.importMap)...) + result = append(result, facts.Fact{ + Kind: facts.KindSymbol, + Name: dir + "." + symbolName + "." + mName, + File: relFile, + Line: int(member.StartPosition().Row) + 1, + Props: map[string]any{ + "symbol_kind": facts.SymbolMethod, + "exported": isExported && !isPrivate, + "language": "typescript", + "receiver": symbolName, + }, + Relations: mRels, + }) } } case "interface_declaration": - name := findChildByKind(node, "type_identifier") - if name != nil { - symbolName := nodeText(name, src) - result = append(result, facts.Fact{ - Kind: facts.KindSymbol, - Name: dir + "." + symbolName, - File: relFile, - Line: int(node.StartPosition().Row) + 1, - Props: map[string]any{ - "symbol_kind": facts.SymbolInterface, - "exported": isExported, - "language": "typescript", - }, - Relations: []facts.Relation{ - {Kind: facts.RelDeclares, Target: dir}, - }, - }) + if name := findChildByKind(node, "type_identifier"); name != nil { + result = append(result, e.simpleSymbol(node, ctx, nodeText(name, src), facts.SymbolInterface, isExported)) } case "type_alias_declaration": - name := findChildByKind(node, "type_identifier") + if name := findChildByKind(node, "type_identifier"); name != nil { + result = append(result, e.simpleSymbol(node, ctx, nodeText(name, src), facts.SymbolType, isExported)) + } + + case "enum_declaration": + name := findChildByKind(node, "identifier") + if name == nil { + name = findChildByKind(node, "type_identifier") + } + if name != nil { + result = append(result, e.simpleSymbol(node, ctx, nodeText(name, src), facts.SymbolEnum, isExported)) + } + + case "internal_module", "module": + // TypeScript `namespace X {}` / `module X {}`. + name := findChildByKind(node, "identifier") + if name == nil { + name = findChildByKind(node, "nested_identifier") + } if name != nil { + result = append(result, e.simpleSymbol(node, ctx, nodeText(name, src), "namespace", isExported)) + } + + case "lexical_declaration", "variable_declaration": + for j := range node.ChildCount() { + decl := node.Child(j) + if decl.Kind() != "variable_declarator" { + continue + } + name := findChildByKind(decl, "identifier") + if name == nil { + continue + } symbolName := nodeText(name, src) - result = append(result, facts.Fact{ + + // Determine the value node and the symbol kind. Arrow functions and + // memo/forwardRef-wrapped values are functions/components; everything + // else is a plain variable. + symbolKind := facts.SymbolVariable + var body *sitter.Node + if v := findChildByKind(decl, "arrow_function"); v != nil { + symbolKind = facts.SymbolFunc + body = v + } else if call := findChildByKind(decl, "call_expression"); call != nil && isComponentWrapper(call, src) { + symbolKind = facts.SymbolFunc + body = call + } + + vRels := []facts.Relation{{Kind: facts.RelDeclares, Target: dir}} + if body != nil { + vRels = append(vRels, collectCalls(body, src, dir, "", ctx.importMap)...) + } + f := facts.Fact{ Kind: facts.KindSymbol, Name: dir + "." + symbolName, File: relFile, Line: int(node.StartPosition().Row) + 1, Props: map[string]any{ - "symbol_kind": facts.SymbolType, + "symbol_kind": symbolKind, "exported": isExported, "language": "typescript", }, - Relations: []facts.Relation{ - {Kind: facts.RelDeclares, Target: dir}, - }, - }) - } - - case "lexical_declaration": - // const/let/var declarations - for j := range node.ChildCount() { - decl := node.Child(j) - if decl.Kind() == "variable_declarator" { - name := findChildByKind(decl, "identifier") - if name != nil { - symbolName := nodeText(name, src) - // Check if the value is an arrow function - symbolKind := facts.SymbolVariable - value := findChildByKind(decl, "arrow_function") - if value != nil { - symbolKind = facts.SymbolFunc - } - - vRels := []facts.Relation{{Kind: facts.RelDeclares, Target: dir}} - if value != nil { - vRels = append(vRels, collectCalls(value, src, dir, "", importMap)...) - } - result = append(result, facts.Fact{ - Kind: facts.KindSymbol, - Name: dir + "." + symbolName, - File: relFile, - Line: int(node.StartPosition().Row) + 1, - Props: map[string]any{ - "symbol_kind": symbolKind, - "exported": isExported, - "language": "typescript", - }, - Relations: vRels, - }) - } + Relations: vRels, } + classifySymbol(&f, symbolName, body, ctx, symbolKind) + result = append(result, f) } } return result } +// funcSymbol builds a function/component symbol fact. declNode supplies the source +// location; body is walked for outgoing calls and JSX-based classification. +func (e *TSExtractor) funcSymbol(declNode, body *sitter.Node, ctx *extractCtx, name string, exported bool) facts.Fact { + rels := []facts.Relation{{Kind: facts.RelDeclares, Target: ctx.dir}} + rels = append(rels, collectCalls(body, ctx.src, ctx.dir, "", ctx.importMap)...) + f := facts.Fact{ + Kind: facts.KindSymbol, + Name: ctx.dir + "." + name, + File: ctx.relFile, + Line: int(declNode.StartPosition().Row) + 1, + Props: map[string]any{ + "symbol_kind": facts.SymbolFunc, + "exported": exported, + "language": "typescript", + }, + Relations: rels, + } + classifySymbol(&f, name, body, ctx, facts.SymbolFunc) + return f +} + +// simpleSymbol builds a declaration-only symbol fact (interface, type, enum, namespace). +func (e *TSExtractor) simpleSymbol(node *sitter.Node, ctx *extractCtx, name, kind string, exported bool) facts.Fact { + f := facts.Fact{ + Kind: facts.KindSymbol, + Name: ctx.dir + "." + name, + File: ctx.relFile, + Line: int(node.StartPosition().Row) + 1, + Props: map[string]any{ + "symbol_kind": kind, + "exported": exported, + "language": "typescript", + }, + Relations: []facts.Relation{{Kind: facts.RelDeclares, Target: ctx.dir}}, + } + return f +} + // detectRoute checks if a file path corresponds to a Next.js route. func detectRoute(relFile string) *facts.Fact { // Next.js App Router: app/**/page.tsx, app/**/route.tsx @@ -591,6 +671,202 @@ func isTypeScriptFile(path string) bool { return ext == ".ts" || ext == ".tsx" } +// hasChildKind reports whether node has a direct child of the given kind. +func hasChildKind(node *sitter.Node, kind string) bool { + return findChildByKind(node, kind) != nil +} + +// firstDeclChild returns the first named declaration child of an export_statement, +// or nil if the export wraps something else (a value, re-export clause, etc.). +func firstDeclChild(node *sitter.Node) *sitter.Node { + for _, k := range []string{ + "function_declaration", "generator_function_declaration", + "class_declaration", "abstract_class_declaration", + "interface_declaration", "type_alias_declaration", + "lexical_declaration", "variable_declaration", + "enum_declaration", "internal_module", "module", + } { + if c := findChildByKind(node, k); c != nil { + return c + } + } + return nil +} + +// fileSymbolName derives a symbol name from a file path for anonymous default +// exports. Generic Next.js filenames (page, route, layout, …) are disambiguated +// with their parent directory segment, e.g. app/dashboard/page.tsx → "DashboardPage". +func fileSymbolName(relFile string) string { + base := filepath.Base(relFile) + base = strings.TrimSuffix(base, filepath.Ext(base)) + switch base { + case "index", "page", "route", "layout", "loading", "error", "not-found", "template", "default": + parent := filepath.Base(filepath.Dir(relFile)) + if parent != "" && parent != "." && parent != string(filepath.Separator) { + return toPascal(parent) + toPascal(base) + } + } + return toPascal(base) +} + +// toPascal converts an arbitrary identifier-ish string into PascalCase, splitting +// on any non-alphanumeric characters (e.g. "my-component" → "MyComponent"). +func toPascal(s string) string { + var b strings.Builder + upNext := true + for _, r := range s { + switch { + case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9'): + if upNext && r >= 'a' && r <= 'z' { + r -= 'a' - 'A' + } + b.WriteRune(r) + upNext = false + default: + upNext = true + } + } + return b.String() +} + +// collectExportedLocalNames returns the set of locally-declared names that are +// exported via a separate `export { A, B as C }` clause or `export default Name` +// statement (where the declaration itself carries no inline export keyword). +func collectExportedLocalNames(root *sitter.Node, src []byte) map[string]bool { + out := make(map[string]bool) + for i := range root.ChildCount() { + child := root.Child(i) + if child.Kind() != "export_statement" { + continue + } + // export { A, B as C } + if clause := findChildByKind(child, "export_clause"); clause != nil { + for j := range clause.ChildCount() { + spec := clause.Child(j) + if spec.Kind() != "export_specifier" { + continue + } + if n := spec.ChildByFieldName("name"); n != nil { + out[nodeText(n, src)] = true + } + } + continue + } + // export default Name + if hasChildKind(child, "default") { + if id := findChildByKind(child, "identifier"); id != nil { + out[nodeText(id, src)] = true + } + } + } + return out +} + +// reactHTTPMethods are the App Router route-handler export names. +var reactHTTPMethods = map[string]bool{ + "GET": true, "POST": true, "PUT": true, "DELETE": true, + "PATCH": true, "HEAD": true, "OPTIONS": true, +} + +// classifySymbol enriches a symbol fact with React/Next.js semantic props +// (web_component, framework, and for route handlers method), mirroring the +// ios_component/framework classification used by the Swift extractor. body, when +// non-nil, is scanned for JSX to confirm component-ness in non-TSX files. +func classifySymbol(f *facts.Fact, name string, body *sitter.Node, ctx *extractCtx, symbolKind string) { + // Next.js App Router route handler: GET/POST/... in a route.{ts,tsx} file. + if symbolKind == facts.SymbolFunc && reactHTTPMethods[name] && isAppRouteFile(ctx.relFile) { + f.Props["web_component"] = "route_handler" + f.Props["method"] = name + f.Props["framework"] = "nextjs" + return + } + // React hook: a useXxx function. + if symbolKind == facts.SymbolFunc && isHookName(name) { + f.Props["web_component"] = "hook" + f.Props["framework"] = "react" + return + } + // React component: a PascalCase function/class that renders JSX. In .tsx/.jsx + // files a PascalCase function/class is treated as a component; elsewhere we + // require literal JSX in the body to avoid misclassifying plain classes. + if isComponentName(name) && (symbolKind == facts.SymbolFunc || symbolKind == facts.SymbolClass) { + if ctx.isTSX || (body != nil && containsJSX(body)) { + f.Props["web_component"] = "component" + if ctx.isNextJS { + f.Props["framework"] = "nextjs" + } else { + f.Props["framework"] = "react" + } + } + } +} + +// isHookName reports whether name follows the React hook convention useXxx. +func isHookName(name string) bool { + if !strings.HasPrefix(name, "use") || len(name) < 4 { + return false + } + c := name[3] + return c >= 'A' && c <= 'Z' +} + +// isComponentName reports whether name is PascalCase (a React component convention). +func isComponentName(name string) bool { + return name != "" && name[0] >= 'A' && name[0] <= 'Z' +} + +// isAppRouteFile reports whether relFile is a Next.js App Router route handler +// file (a route.{ts,tsx} under an "app" directory segment). +func isAppRouteFile(relFile string) bool { + base := filepath.Base(relFile) + base = strings.TrimSuffix(strings.TrimSuffix(base, ".tsx"), ".ts") + if base != "route" { + return false + } + for _, seg := range strings.Split(filepath.ToSlash(relFile), "/") { + if seg == "app" { + return true + } + } + return false +} + +// containsJSX reports whether the subtree rooted at node contains a JSX element. +func containsJSX(node *sitter.Node) bool { + if node == nil { + return false + } + switch node.Kind() { + case "jsx_element", "jsx_self_closing_element", "jsx_fragment": + return true + } + for i := range node.ChildCount() { + if containsJSX(node.Child(i)) { + return true + } + } + return false +} + +// isComponentWrapper reports whether a call expression wraps a component, i.e. it +// calls memo / forwardRef (optionally as React.memo / React.forwardRef). +func isComponentWrapper(call *sitter.Node, src []byte) bool { + fn := call.ChildByFieldName("function") + if fn == nil { + return false + } + name := "" + switch fn.Kind() { + case "identifier": + name = nodeText(fn, src) + case "member_expression": + if prop := fn.ChildByFieldName("property"); prop != nil { + name = nodeText(prop, src) + } + } + return name == "memo" || name == "forwardRef" +} + func findChildByKind(node *sitter.Node, kind string) *sitter.Node { for i := range node.ChildCount() { child := node.Child(i) diff --git a/internal/extractors/tsextractor/ts_test.go b/internal/extractors/tsextractor/ts_test.go index 28dd8d8..b56e969 100644 --- a/internal/extractors/tsextractor/ts_test.go +++ b/internal/extractors/tsextractor/ts_test.go @@ -461,6 +461,189 @@ func TestExtract_CallExtraction_MethodOnReceiver_NoEdge(t *testing.T) { } } +// --- React / Next.js semantic classification & coverage tests --- + +func TestExtract_DefaultExportFunctionComponent(t *testing.T) { + ff := extractAll(t, map[string]string{ + "src/components/Button.tsx": `export default function Button() { return }`, + }, true) + + f, ok := findFact(ff, "src/components.Button") + if !ok { + t.Fatal("expected fact for src/components.Button") + } + if f.Props["symbol_kind"] != facts.SymbolFunc { + t.Errorf("symbol_kind = %v, want function", f.Props["symbol_kind"]) + } + if f.Props["exported"] != true { + t.Errorf("exported = %v, want true", f.Props["exported"]) + } + if f.Props["web_component"] != "component" { + t.Errorf("web_component = %v, want component", f.Props["web_component"]) + } + if f.Props["framework"] != "nextjs" { + t.Errorf("framework = %v, want nextjs", f.Props["framework"]) + } +} + +func TestExtract_AnonymousDefaultExport_NamedByFile(t *testing.T) { + // Anonymous default exports are named after the file (parent dir for generic + // Next.js page filenames). + ff := extractAll(t, map[string]string{ + "src/app/dashboard/page.tsx": `export default function() { return
}`, + "src/components/Card.tsx": `export default () => `, + }, true) + + page, ok := findFact(ff, "src/app/dashboard.DashboardPage") + if !ok { + t.Fatalf("expected fact src/app/dashboard.DashboardPage; got %v", factNames(ff)) + } + if page.Props["exported"] != true { + t.Errorf("page exported = %v, want true", page.Props["exported"]) + } + if page.Props["web_component"] != "component" { + t.Errorf("page web_component = %v, want component", page.Props["web_component"]) + } + + card, ok := findFact(ff, "src/components.Card") + if !ok { + t.Fatalf("expected fact src/components.Card (anon default arrow); got %v", factNames(ff)) + } + if card.Props["web_component"] != "component" { + t.Errorf("card web_component = %v, want component", card.Props["web_component"]) + } +} + +func TestExtract_MemoWrappedComponent(t *testing.T) { + ff := extractAll(t, map[string]string{ + "src/components/Card.tsx": `import { memo } from 'react' +const Card = memo(function Card() { return }) +export default Card`, + }, true) + + f, ok := findFact(ff, "src/components.Card") + if !ok { + t.Fatalf("expected fact src/components.Card; got %v", factNames(ff)) + } + // memo-wrapped value should be a function/component, not a plain variable. + if f.Props["symbol_kind"] != facts.SymbolFunc { + t.Errorf("symbol_kind = %v, want function (memo-wrapped)", f.Props["symbol_kind"]) + } + if f.Props["web_component"] != "component" { + t.Errorf("web_component = %v, want component", f.Props["web_component"]) + } + // Exported via `export default Card`. + if f.Props["exported"] != true { + t.Errorf("exported = %v, want true (export default Card)", f.Props["exported"]) + } +} + +func TestExtract_ReExportMarksExported(t *testing.T) { + ff := extractAll(t, map[string]string{ + "src/utils.ts": `function helper() { return 1 } +const value = 2 +export { helper, value }`, + }, false) + + helper, ok := findFact(ff, "src.helper") + if !ok { + t.Fatal("expected fact src.helper") + } + if helper.Props["exported"] != true { + t.Errorf("helper exported = %v, want true (export { helper })", helper.Props["exported"]) + } + value, ok := findFact(ff, "src.value") + if !ok { + t.Fatal("expected fact src.value") + } + if value.Props["exported"] != true { + t.Errorf("value exported = %v, want true (export { value })", value.Props["exported"]) + } +} + +func TestExtract_HookClassification(t *testing.T) { + ff := extractAll(t, map[string]string{ + "src/hooks/useAuth.ts": `export function useAuth() { return null } +export const useUser = () => null`, + }, false) + + for _, name := range []string{"src/hooks.useAuth", "src/hooks.useUser"} { + f, ok := findFact(ff, name) + if !ok { + t.Fatalf("expected fact %s", name) + } + if f.Props["web_component"] != "hook" { + t.Errorf("%s web_component = %v, want hook", name, f.Props["web_component"]) + } + if f.Props["framework"] != "react" { + t.Errorf("%s framework = %v, want react", name, f.Props["framework"]) + } + } +} + +func TestExtract_RouteHandlerClassification(t *testing.T) { + ff := extractAll(t, map[string]string{ + "src/app/api/users/route.ts": `export async function GET() { return Response.json([]) } +export async function POST() { return Response.json({}) }`, + }, true) + + get, ok := findFact(ff, "src/app/api/users.GET") + if !ok { + t.Fatalf("expected fact src/app/api/users.GET; got %v", factNames(ff)) + } + if get.Props["web_component"] != "route_handler" { + t.Errorf("GET web_component = %v, want route_handler", get.Props["web_component"]) + } + if get.Props["method"] != "GET" { + t.Errorf("GET method = %v, want GET", get.Props["method"]) + } + if _, ok := findFact(ff, "src/app/api/users.POST"); !ok { + t.Error("expected fact src/app/api/users.POST") + } +} + +func TestExtract_Enum(t *testing.T) { + ff := extractAll(t, map[string]string{ + "src/types/colors.ts": `export enum Color { Red, Green, Blue }`, + }, false) + + f, ok := findFact(ff, "src/types.Color") + if !ok { + t.Fatalf("expected fact src/types.Color; got %v", factNames(ff)) + } + if f.Props["symbol_kind"] != facts.SymbolEnum { + t.Errorf("symbol_kind = %v, want enum", f.Props["symbol_kind"]) + } + if f.Props["exported"] != true { + t.Errorf("exported = %v, want true", f.Props["exported"]) + } +} + +func TestExtract_NonComponentClassNotClassified(t *testing.T) { + // A PascalCase service class with no JSX in a .ts file must not be tagged a component. + ff := extractAll(t, map[string]string{ + "src/services/ApiClient.ts": `export class ApiClient { fetchAll() { return [] } }`, + }, false) + + f, ok := findFact(ff, "src/services.ApiClient") + if !ok { + t.Fatal("expected fact src/services.ApiClient") + } + if _, tagged := f.Props["web_component"]; tagged { + t.Errorf("ApiClient should not be classified as a component; got %v", f.Props["web_component"]) + } +} + +func factNames(ff []facts.Fact) []string { + var names []string + for _, f := range ff { + if f.Kind == facts.KindSymbol { + names = append(names, f.Name) + } + } + return names +} + func TestIsTypeScriptFile(t *testing.T) { tests := []struct { path string diff --git a/internal/facts/graph.go b/internal/facts/graph.go index ff0a448..8302736 100644 --- a/internal/facts/graph.go +++ b/internal/facts/graph.go @@ -66,6 +66,7 @@ type ImpactResult struct { Target string `json:"target"` ByDepth map[int][]TraversalNode `json:"by_depth"` Edges []TraversalEdge `json:"edges"` + TotalDependents int `json:"total_dependents"` // true count of transitive dependents within max_depth, independent of the max_nodes display cap Summary string `json:"summary"` Stats TraversalStats `json:"stats"` Forward *TraversalResult `json:"forward_dependencies,omitempty"` @@ -338,6 +339,25 @@ func (g *Graph) traverseFrom(starts []string, direction string, relKinds, nodeKi result.Stats.MaxDepthReached = maxDepthReached result.Stats.Truncated = truncated + // Edges are recorded for every relation walked, but the max_nodes cap and the + // node-kind filter can exclude some destinations from result.Nodes. Drop edges + // that reference an excluded node so the returned graph is self-consistent + // (every edge endpoint appears in Nodes). Only needed when something was + // excluded; otherwise every visited node is already in Nodes. + if truncated || kindSet != nil { + inSet := make(map[string]bool, len(result.Nodes)) + for _, n := range result.Nodes { + inSet[n.Name] = true + } + kept := result.Edges[:0] + for _, e := range result.Edges { + if inSet[e.Source] && inSet[e.Target] { + kept = append(kept, e) + } + } + result.Edges = kept + } + return result } @@ -466,11 +486,18 @@ func (g *Graph) ImpactSet(target string, maxDepth, maxNodes int, includeForward seeds := g.impactSeeds(target) rev := g.traverseFrom(seeds, "reverse", nil, nil, maxDepth, maxNodes) + // The max_nodes cap stops the BFS frontier, so rev's node/visited counts do + // not reflect the true dependent count. Compute it with a cheap count-only + // pass (same seeds, same depth, no node cap) so the summary is accurate even + // when the displayed set is truncated. + totalDependents := g.reachableCount(seeds, "reverse", maxDepth) + result := ImpactResult{ - Target: target, - ByDepth: make(map[int][]TraversalNode), - Edges: rev.Edges, - Stats: rev.Stats, + Target: target, + ByDepth: make(map[int][]TraversalNode), + Edges: rev.Edges, + TotalDependents: totalDependents, + Stats: rev.Stats, } // Bucket nodes by depth (skip depth 0, which holds the target entity's own @@ -495,7 +522,7 @@ func (g *Graph) ImpactSet(target string, maxDepth, maxNodes int, includeForward } // Build summary - result.Summary = g.buildImpactSummary(result.ByDepth) + result.Summary = g.buildImpactSummary(result.ByDepth, totalDependents) if len(result.CrossRepoImpact) > 0 { result.Summary += " — spans repos: " + strings.Join(result.CrossRepoImpact, ", ") } @@ -701,14 +728,18 @@ func (g *Graph) nodeFor(name string, depth int) TraversalNode { return node } -func (g *Graph) buildImpactSummary(byDepth map[int][]TraversalNode) string { - if len(byDepth) == 0 { +// buildImpactSummary renders the per-depth breakdown of the displayed dependents. +// total is the true dependent count within max_depth; when it exceeds the shown +// count (because the max_nodes cap truncated the display), the summary notes how +// many are shown. +func (g *Graph) buildImpactSummary(byDepth map[int][]TraversalNode, total int) string { + if total == 0 { return "No dependents found." } - total := 0 + shown := 0 for _, nodes := range byDepth { - total += len(nodes) + shown += len(nodes) } summary := "" @@ -743,7 +774,59 @@ func (g *Graph) buildImpactSummary(byDepth map[int][]TraversalNode) string { } } - return itoa(total) + " total dependents — " + summary + prefix := itoa(total) + " total dependents" + if shown < total { + prefix += " (showing " + itoa(shown) + ")" + } + return prefix + " — " + summary +} + +// reachableCount counts the distinct nodes reachable from seeds within maxDepth +// (following all relation kinds), excluding the seeds themselves. Unlike +// traverseFrom it materializes nothing and applies no node cap, so it yields the +// true dependent/dependency count even when the displayed set is truncated. It +// terminates because the graph is finite and the BFS is depth-bounded. +func (g *Graph) reachableCount(seeds []string, direction string, maxDepth int) int { + g.mu.RLock() + defer g.mu.RUnlock() + + if maxDepth <= 0 { + maxDepth = 3 + } + adj := g.forward + if direction == "reverse" { + adj = g.reverse + } + + visited := make(map[string]bool) + type queueItem struct { + name string + depth int + } + var queue []queueItem + for _, s := range seeds { + if !visited[s] { + visited[s] = true + queue = append(queue, queueItem{name: s, depth: 0}) + } + } + seedCount := len(visited) + + for qi := 0; qi < len(queue); qi++ { + item := queue[qi] + if item.depth >= maxDepth { + continue + } + for _, e := range adj[item.name] { + if visited[e.Target] { + continue + } + visited[e.Target] = true + queue = append(queue, queueItem{name: e.Target, depth: item.depth + 1}) + } + } + + return len(visited) - seedCount } func toSet(ss []string) map[string]struct{} { diff --git a/internal/facts/graph_test.go b/internal/facts/graph_test.go index 681f931..b97efc8 100644 --- a/internal/facts/graph_test.go +++ b/internal/facts/graph_test.go @@ -2,6 +2,7 @@ package facts import ( "reflect" + "strings" "testing" ) @@ -145,6 +146,26 @@ func TestTraverse_MaxNodesLimit(t *testing.T) { } } +func TestTraverse_EdgesConsistentWhenTruncated(t *testing.T) { + g, _ := buildTestGraph() + + // maxNodes=2 truncates the result; every returned edge must still reference + // only nodes present in result.Nodes (no dangling edges to capped-out nodes). + result := g.Traverse("A", "forward", nil, nil, 10, 2) + if !result.Stats.Truncated { + t.Fatal("expected truncation with maxNodes=2") + } + inSet := map[string]bool{} + for _, n := range result.Nodes { + inSet[n.Name] = true + } + for _, e := range result.Edges { + if !inSet[e.Source] || !inSet[e.Target] { + t.Errorf("edge %s -> %s references a node absent from result.Nodes %v", e.Source, e.Target, nodeNames(result.Nodes)) + } + } +} + func TestTraverse_RelationKindFilter(t *testing.T) { g, _ := buildTestGraph() @@ -358,6 +379,62 @@ func TestImpactSet_Basic(t *testing.T) { } } +func TestReachableCount(t *testing.T) { + g, _ := buildTestGraph() + + // Reverse from C: B and E depend on C directly, A transitively. 3 total. + if got := g.reachableCount([]string{"C"}, "reverse", 10); got != 3 { + t.Errorf("reachableCount(C, reverse) = %d, want 3", got) + } + // Forward from A: B, E (direct), C, D (transitive). 4 total. + if got := g.reachableCount([]string{"A"}, "forward", 10); got != 4 { + t.Errorf("reachableCount(A, forward) = %d, want 4", got) + } + // Depth limit: reverse from C at depth 1 reaches only B and E. + if got := g.reachableCount([]string{"C"}, "reverse", 1); got != 2 { + t.Errorf("reachableCount(C, reverse, depth 1) = %d, want 2", got) + } +} + +func TestReachableCount_Cyclic(t *testing.T) { + g, _ := buildCyclicGraph() + // A -> B -> C -> A. Reverse from A reaches C then B (A is the seed). 2 total, + // and the cycle must not loop forever. + if got := g.reachableCount([]string{"A"}, "reverse", 10); got != 2 { + t.Errorf("reachableCount(A, reverse) on cycle = %d, want 2", got) + } +} + +func TestImpactSet_TotalDependents(t *testing.T) { + g, _ := buildTestGraph() + + // Not truncated: total equals the shown dependents (B, E, A = 3). + result := g.ImpactSet("C", 10, 100, false) + if result.TotalDependents != 3 { + t.Errorf("TotalDependents = %d, want 3", result.TotalDependents) + } + if strings.Contains(result.Summary, "showing") { + t.Errorf("summary should not mention 'showing' when not truncated: %q", result.Summary) + } +} + +func TestImpactSet_TotalDependents_Truncated(t *testing.T) { + g, _ := buildTestGraph() + + // maxNodes=2 leaves room for the seed (C) plus one dependent, so the display + // is truncated but the total must still report all 3 dependents. + result := g.ImpactSet("C", 10, 2, false) + if result.TotalDependents != 3 { + t.Errorf("TotalDependents = %d, want 3 (accurate despite cap)", result.TotalDependents) + } + if !result.Stats.Truncated { + t.Error("expected Stats.Truncated with maxNodes=2") + } + if !strings.Contains(result.Summary, "3 total dependents (showing 1)") { + t.Errorf("summary should report accurate total and showing count; got %q", result.Summary) + } +} + func TestImpactSet_WithForward(t *testing.T) { g, _ := buildTestGraph() diff --git a/internal/facts/model.go b/internal/facts/model.go index 64afedc..e9f8ff2 100644 --- a/internal/facts/model.go +++ b/internal/facts/model.go @@ -49,6 +49,7 @@ const ( SymbolClass = "class" SymbolVariable = "variable" SymbolConstant = "constant" + SymbolEnum = "enum" ) // Insight represents an architectural insight produced by an explainer. diff --git a/internal/server/server.go b/internal/server/server.go index 99d2253..81d4ec8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,6 +7,7 @@ import ( "log" "os" "path/filepath" + "sort" "strings" "time" @@ -550,6 +551,7 @@ func (s *Server) registerTools() { "Forward traversal from a struct/interface follows has_method edges to its methods (and then their calls). " + "Note: interface method calls cannot be statically bound to a concrete implementation, so such call edges may be absent or appear as unresolved nodes. " + "node_kinds filters output (not traversal itself): module, symbol, dependency, route, storage. " + + "Returns a compact markdown summary grouped by depth (output_mode='full' for the raw JSON node/edge graph). " + "Defaults: depth=5, max_nodes=100. Use instead of repeated explore calls for transitive relationships.", }, func(ctx context.Context, req *mcp.CallToolRequest, args traverseArgs) (*mcp.CallToolResult, any, error) { if s.toolCallback != nil { @@ -575,13 +577,17 @@ func (s *Server) registerTools() { } // Over threshold: refuse to guess; return resolution with empty results. if res != nil && res.Matched == "" { - return jsonResult(traverseResponse{ + resp := traverseResponse{ Resolution: res, TraversalResult: facts.TraversalResult{ Nodes: []facts.TraversalNode{}, Edges: []facts.TraversalEdge{}, }, - }) + } + if wantsFullOutput(args.OutputMode) { + return jsonResult(resp) + } + return textResult(renderTraverseCompact(resp, args.Start, "")), nil, nil } direction := args.Direction @@ -594,7 +600,11 @@ func (s *Server) registerTools() { result := graph.Traverse(startName, direction, args.RelationKinds, args.NodeKinds, args.MaxDepth, args.MaxNodes) - return jsonResult(traverseResponse{Resolution: res, TraversalResult: result}) + resp := traverseResponse{Resolution: res, TraversalResult: result} + if wantsFullOutput(args.OutputMode) { + return jsonResult(resp) + } + return textResult(renderTraverseCompact(resp, startName, direction)), nil, nil }) // Tool: find_path @@ -659,6 +669,7 @@ func (s *Server) registerTools() { "target= uses substring match with smart disambiguation. " + "Default: reverse direction only (what breaks if target changes). " + "Set include_forward=true to also see what the target itself depends on (useful for understanding what could break the target). " + + "Returns a compact markdown summary grouped by hop depth, with an accurate total dependent count (output_mode='full' for the raw JSON). " + "Defaults: max_depth=3, max_nodes=200.", }, func(ctx context.Context, req *mcp.CallToolRequest, args impactAnalysisArgs) (*mcp.CallToolResult, any, error) { if s.toolCallback != nil { @@ -683,19 +694,27 @@ func (s *Server) registerTools() { } // Over threshold: refuse to guess; return resolution with empty results. if res != nil && res.Matched == "" { - return jsonResult(impactResponse{ + resp := impactResponse{ Resolution: res, ImpactResult: facts.ImpactResult{ Target: args.Target, ByDepth: map[int][]facts.TraversalNode{}, Edges: []facts.TraversalEdge{}, }, - }) + } + if wantsFullOutput(args.OutputMode) { + return jsonResult(resp) + } + return textResult(renderImpactCompact(resp)), nil, nil } result := graph.ImpactSet(targetName, args.MaxDepth, args.MaxNodes, args.IncludeForward) - return jsonResult(impactResponse{Resolution: res, ImpactResult: result}) + resp := impactResponse{Resolution: res, ImpactResult: result} + if wantsFullOutput(args.OutputMode) { + return jsonResult(resp) + } + return textResult(renderImpactCompact(resp)), nil, nil }) } @@ -950,6 +969,7 @@ type traverseArgs struct { MaxDepth int `json:"max_depth,omitempty" jsonschema:"Maximum traversal depth (1-20). Default: 5."` MaxNodes int `json:"max_nodes,omitempty" jsonschema:"Maximum nodes to return (1-500). Traversal stops when this limit is reached. Default: 100."` NodeKinds []string `json:"node_kinds,omitempty" jsonschema:"Filter results to specific fact kinds: module, symbol, dependency, route, storage. Default: all."` + OutputMode string `json:"output_mode,omitempty" jsonschema:"'compact' (default) returns a readable markdown summary grouped by depth; 'full' returns the complete JSON node/edge graph (can be large)."` } // findPathArgs are the arguments for the find_path tool. @@ -966,6 +986,7 @@ type impactAnalysisArgs struct { MaxDepth int `json:"max_depth,omitempty" jsonschema:"How many hops of impact to compute (1-10). Default: 3."` MaxNodes int `json:"max_nodes,omitempty" jsonschema:"Maximum impacted nodes to return (1-500). Default: 200."` IncludeForward bool `json:"include_forward,omitempty" jsonschema:"Include what the target depends on (what might break the target). Default: false."` + OutputMode string `json:"output_mode,omitempty" jsonschema:"'compact' (default) returns a readable markdown summary grouped by depth; 'full' returns the complete JSON by_depth/edges graph (can be large)."` } // exploreModule renders a module exploration if the focus matches a module name. @@ -1073,6 +1094,12 @@ func (s *Server) exploreModule(store *facts.Store, focus string, depth int, sb * sb.WriteString("\n") } + // Nested subtree: modules and symbols beneath this directory. TypeScript/JS + // (and any per-directory module language) nests modules per directory, so a + // directory that is itself a module often has a large subtree of child + // modules whose symbols would otherwise be hidden by this exact-module match. + s.writeNestedModules(store, mod.Name, len(declaredSymbols), sb) + // If depth=2, show key symbol relations if depth >= 2 && len(declaredSymbols) > 0 { sb.WriteString("## Symbol Relations\n\n") @@ -1098,6 +1125,69 @@ func (s *Server) exploreModule(store *facts.Store, focus string, depth int, sb * return true } +// writeNestedModules appends a summary of the modules and symbols nested beneath +// modName (i.e. facts whose file path is under "