From 8e022bf5a593c8bbeba8d5ec19e205e1f4fad7cf Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Mon, 27 Apr 2026 15:56:36 -0300 Subject: [PATCH] feat(auto): implement stack detection with csharp/react/go/python heuristics Co-Authored-By: Claude Sonnet 4.6 --- internal/plugins/auto/auto.go | 171 ++++++++++++++++++-- internal/plugins/auto/detector.go | 258 ++++++++++++++++++++++++++++++ internal/plugins/csharp/csharp.go | 3 + internal/plugins/react/react.go | 3 + 4 files changed, 423 insertions(+), 12 deletions(-) create mode 100644 internal/plugins/auto/detector.go diff --git a/internal/plugins/auto/auto.go b/internal/plugins/auto/auto.go index 303aacb..d2128da 100644 --- a/internal/plugins/auto/auto.go +++ b/internal/plugins/auto/auto.go @@ -1,31 +1,178 @@ -// Package auto implements the ctx auto plugin. -// Full implementation: prompt 2. +// Package auto implements the ctx auto plugin — stack detection and routing. package auto import ( "fmt" + "strings" "github.com/ricarneiro/ctx/internal/core" "github.com/spf13/cobra" ) func init() { - core.Register(&autoPlugin{}) + core.Register(&plugin{}) } -type autoPlugin struct{} +type plugin struct{} -func (a *autoPlugin) Name() string { return "auto" } -func (a *autoPlugin) Version() string { return "0.0.1" } -func (a *autoPlugin) ShortDescription() string { return "Auto-detect project stack and emit context" } +func (p *plugin) Name() string { return "auto" } +func (p *plugin) Version() string { return "0.1.0" } +func (p *plugin) ShortDescription() string { return "Detect stack and route to appropriate plugin" } -func (a *autoPlugin) Command(ctx *core.Context) *cobra.Command { - return &cobra.Command{ +func (p *plugin) Command(ctx *core.Context) *cobra.Command { + cmd := &cobra.Command{ Use: "auto", - Short: a.ShortDescription(), + Short: p.ShortDescription(), + Long: "Auto-detect the project stack and route to the appropriate ctx plugin.", + } + cmd.AddCommand(newDetectCmd(ctx)) + cmd.AddCommand(newProjectCmd(ctx)) + return cmd +} + +// --- detect subcommand --- + +func newDetectCmd(ctx *core.Context) *cobra.Command { + return &cobra.Command{ + Use: "detect", + Short: "List detected stacks in the current directory", + Long: "Scan the current directory and list detected technology stacks without running any plugin.", RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 2") - return nil + return runDetect(ctx) }, } } + +func runDetect(ctx *core.Context) error { + stacks, err := Detect(ctx.WorkDir) + if err != nil { + fmt.Fprintln(ctx.Stderr, err.Error()) + return fmt.Errorf("exit 1") + } + formatDetect(ctx, stacks) + return nil +} + +func formatDetect(ctx *core.Context, stacks []Stack) { + w := ctx.Stdout + fmt.Fprintln(w, "# Stack detection") + fmt.Fprintln(w) + fmt.Fprintf(w, "**directory:** %s\n\n", ctx.WorkDir) + + if len(stacks) == 0 { + fmt.Fprintln(w, "No known stack detected.") + fmt.Fprintln(w) + plugins := core.All() + names := make([]string, len(plugins)) + for i, p := range plugins { + names[i] = p.Name() + } + fmt.Fprintf(w, "Available plugins: %s\n", strings.Join(names, ", ")) + fmt.Fprintln(w, "Run `ctx --help` to see what each plugin offers.") + return + } + + fmt.Fprintln(w, "## Detected stacks") + for _, s := range stacks { + fmt.Fprintf(w, "- **%s** (confidence: %s)\n", s.Name, s.Confidence) + for _, e := range s.Evidence { + fmt.Fprintf(w, " - %s\n", e) + } + } + fmt.Fprintln(w) + + fmt.Fprintln(w, "## Suggested commands") + for _, s := range stacks { + fmt.Fprintf(w, "- `ctx %s project`\n", s.Name) + } +} + +// --- project subcommand --- + +func newProjectCmd(ctx *core.Context) *cobra.Command { + return &cobra.Command{ + Use: "project", + Short: "Detect stack and emit project context summary", + Long: "Auto-detect the project stack and run the `project` command of each matching plugin.", + RunE: func(cmd *cobra.Command, args []string) error { + return runProject(ctx) + }, + } +} + +func runProject(ctx *core.Context) error { + stacks, err := Detect(ctx.WorkDir) + if err != nil { + fmt.Fprintln(ctx.Stderr, err.Error()) + return fmt.Errorf("exit 1") + } + + if len(stacks) == 0 { + formatDetect(ctx, stacks) + return nil + } + + first := true + for _, stack := range stacks { + if !first { + fmt.Fprintln(ctx.Stdout, "---") + fmt.Fprintln(ctx.Stdout) + } + first = false + + p := core.Get(stack.Name) + if p == nil { + fmt.Fprintf(ctx.Stdout, + "# %s\n\nNo plugin registered for stack `%s`.\n\n", + stack.Name, stack.Name) + continue + } + + pluginCmd := p.Command(ctx) + + // If the root command itself is a placeholder, the whole plugin is unimplemented. + if pluginCmd.Annotations["placeholder"] == "true" { + writePlaceholder(ctx, stack.Name) + continue + } + + sub := findSubcommand(pluginCmd, "project") + if sub == nil { + fmt.Fprintf(ctx.Stdout, + "# %s\n\nThe `%s` plugin does not have a `project` command.\n\n", + stack.Name, stack.Name) + continue + } + + if sub.Annotations["placeholder"] == "true" { + writePlaceholder(ctx, stack.Name) + continue + } + + // Execute the project subcommand directly (no cobra routing overhead). + if sub.RunE != nil { + if err := sub.RunE(sub, []string{}); err != nil { + fmt.Fprintf(ctx.Stderr, "error running %s project: %v\n", stack.Name, err) + } + } else if sub.Run != nil { + sub.Run(sub, []string{}) + } + } + return nil +} + +// findSubcommand returns the named subcommand of cmd, or nil if not found. +func findSubcommand(cmd *cobra.Command, name string) *cobra.Command { + for _, sub := range cmd.Commands() { + if sub.Name() == name { + return sub + } + } + return nil +} + +func writePlaceholder(ctx *core.Context, name string) { + fmt.Fprintf(ctx.Stdout, + "# %s (placeholder)\n\nThe `%s` plugin detected this stack but its `project` command is not yet implemented.\nThis will be available in a future version of ctx.\n\n", + name, name) +} diff --git a/internal/plugins/auto/detector.go b/internal/plugins/auto/detector.go new file mode 100644 index 0000000..9f23895 --- /dev/null +++ b/internal/plugins/auto/detector.go @@ -0,0 +1,258 @@ +package auto + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// Stack represents a detected technology stack in a project directory. +type Stack struct { + Name string // "csharp", "react", "go", "python" + Confidence string // "high", "medium", "low" + Evidence []string // human-readable items explaining the detection +} + +// Detect scans rootDir up to 3 levels deep and returns detected stacks, +// ordered by confidence (high first). Build artifacts and dependency +// directories (node_modules, bin, obj, etc.) are skipped. +func Detect(rootDir string) ([]Stack, error) { + f, err := scanDir(rootDir, 3) + if err != nil { + return nil, err + } + return buildStacks(f), nil +} + +// skipDirs lists directory names that are never descended into during scanning. +var skipDirs = map[string]bool{ + "node_modules": true, + "bin": true, + "obj": true, + "dist": true, + "build": true, + "out": true, + ".git": true, + ".vs": true, + ".vscode": true, + ".idea": true, + "target": true, + "vendor": true, +} + +// findings holds raw evidence gathered during the directory walk. +type findings struct { + slnFiles []string // forward-slash relative paths to .sln files + csprojFiles []string // forward-slash relative paths to .csproj/.fsproj/.vbproj files + hasGlobalJSON bool + packageJSONs []pkgJSON + hasGoModRoot bool // go.mod is a direct child of rootDir (depth 1) + hasPyProject bool + hasReqTxt bool + hasSetupPy bool + hasTSXorJSX bool +} + +type pkgJSON struct { + relPath string + hasReact bool + hasTypeScript bool +} + +func scanDir(rootDir string, maxDepth int) (findings, error) { + var f findings + + err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // skip unreadable entries silently + } + if path == rootDir { + return nil // skip root itself + } + + rel, _ := filepath.Rel(rootDir, path) + depth := len(strings.Split(rel, string(filepath.Separator))) + relFwd := filepath.ToSlash(rel) + + if d.IsDir() { + if skipDirs[d.Name()] { + return filepath.SkipDir + } + if depth >= maxDepth { + return filepath.SkipDir + } + return nil + } + + // File: classify by extension then by base name. + name := d.Name() + base := strings.ToLower(name) + ext := strings.ToLower(filepath.Ext(name)) + + switch ext { + case ".sln": + f.slnFiles = append(f.slnFiles, relFwd) + case ".csproj", ".fsproj", ".vbproj": + f.csprojFiles = append(f.csprojFiles, relFwd) + case ".tsx", ".jsx": + f.hasTSXorJSX = true + } + + switch base { + case "global.json": + f.hasGlobalJSON = true + case "package.json": + f.packageJSONs = append(f.packageJSONs, parsePackageJSON(path, relFwd)) + case "go.mod": + if depth == 1 { + f.hasGoModRoot = true + } + case "pyproject.toml": + f.hasPyProject = true + case "requirements.txt": + f.hasReqTxt = true + case "setup.py": + f.hasSetupPy = true + } + + return nil + }) + + return f, err +} + +// pkgJSONSchema is the minimal structure needed to check for specific deps. +type pkgJSONSchema struct { + Dependencies map[string]json.RawMessage `json:"dependencies"` + DevDependencies map[string]json.RawMessage `json:"devDependencies"` +} + +func parsePackageJSON(path, relPath string) pkgJSON { + result := pkgJSON{relPath: relPath} + b, err := os.ReadFile(path) + if err != nil { + return result + } + var raw pkgJSONSchema + if err := json.Unmarshal(b, &raw); err != nil { + return result + } + allDeps := make(map[string]bool, len(raw.Dependencies)+len(raw.DevDependencies)) + for k := range raw.Dependencies { + allDeps[k] = true + } + for k := range raw.DevDependencies { + allDeps[k] = true + } + result.hasReact = allDeps["react"] + result.hasTypeScript = allDeps["typescript"] || allDeps["@types/react"] + return result +} + +// buildStacks applies heuristics to the findings and returns stacks sorted +// by confidence descending. +func buildStacks(f findings) []Stack { + var stacks []Stack + + // C# — high if .sln or .csproj present; medium if only global.json. + if len(f.slnFiles) > 0 || len(f.csprojFiles) > 0 { + var evidence []string + for _, s := range f.slnFiles { + evidence = append(evidence, evidenceItem(s)) + } + for i, s := range f.csprojFiles { + if i >= 3 { + evidence = append(evidence, fmt.Sprintf("...and %d more .csproj files", len(f.csprojFiles)-3)) + break + } + evidence = append(evidence, evidenceItem(s)) + } + stacks = append(stacks, Stack{Name: "csharp", Confidence: "high", Evidence: evidence}) + } else if f.hasGlobalJSON { + stacks = append(stacks, Stack{ + Name: "csharp", Confidence: "medium", + Evidence: []string{"found: `global.json`"}, + }) + } + + // React — high if package.json lists react; medium if TypeScript + tsx/jsx files. + reactFound := false + for _, pkg := range f.packageJSONs { + if pkg.hasReact { + stacks = append(stacks, Stack{ + Name: "react", + Confidence: "high", + Evidence: []string{fmt.Sprintf("found: `%s` with `react` in dependencies", pkg.relPath)}, + }) + reactFound = true + break + } + } + if !reactFound { + for _, pkg := range f.packageJSONs { + if pkg.hasTypeScript && f.hasTSXorJSX { + stacks = append(stacks, Stack{ + Name: "react", + Confidence: "medium", + Evidence: []string{fmt.Sprintf("found: `%s` with TypeScript + `.tsx`/`.jsx` files", pkg.relPath)}, + }) + break + } + } + } + + // Go — high if go.mod is at the root (not nested). + if f.hasGoModRoot { + stacks = append(stacks, Stack{ + Name: "go", Confidence: "high", + Evidence: []string{"found: `go.mod`"}, + }) + } + + // Python — medium if any standard config file is present. + if f.hasPyProject || f.hasReqTxt || f.hasSetupPy { + var evidence []string + if f.hasPyProject { + evidence = append(evidence, "found: `pyproject.toml`") + } + if f.hasReqTxt { + evidence = append(evidence, "found: `requirements.txt`") + } + if f.hasSetupPy { + evidence = append(evidence, "found: `setup.py`") + } + stacks = append(stacks, Stack{Name: "python", Confidence: "medium", Evidence: evidence}) + } + + sort.SliceStable(stacks, func(i, j int) bool { + return confidenceRank(stacks[i].Confidence) > confidenceRank(stacks[j].Confidence) + }) + + return stacks +} + +// evidenceItem formats a relative path for evidence display. +// Root-level files get an "at root" suffix. +func evidenceItem(relFwd string) string { + if !strings.Contains(relFwd, "/") { + return fmt.Sprintf("found: `%s` at root", relFwd) + } + return fmt.Sprintf("found: `%s`", relFwd) +} + +func confidenceRank(c string) int { + switch c { + case "high": + return 3 + case "medium": + return 2 + case "low": + return 1 + default: + return 0 + } +} diff --git a/internal/plugins/csharp/csharp.go b/internal/plugins/csharp/csharp.go index 7878d81..2f3070d 100644 --- a/internal/plugins/csharp/csharp.go +++ b/internal/plugins/csharp/csharp.go @@ -23,6 +23,9 @@ func (c *csharpPlugin) Command(ctx *core.Context) *cobra.Command { return &cobra.Command{ Use: "csharp", Short: c.ShortDescription(), + // placeholder=true tells ctx auto to show a placeholder message instead of + // attempting to invoke this plugin's subcommands. + Annotations: map[string]string{"placeholder": "true"}, RunE: func(cmd *cobra.Command, args []string) error { fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 4") return nil diff --git a/internal/plugins/react/react.go b/internal/plugins/react/react.go index c20e461..12fb27f 100644 --- a/internal/plugins/react/react.go +++ b/internal/plugins/react/react.go @@ -23,6 +23,9 @@ func (r *reactPlugin) Command(ctx *core.Context) *cobra.Command { return &cobra.Command{ Use: "react", Short: r.ShortDescription(), + // placeholder=true tells ctx auto to show a placeholder message instead of + // attempting to invoke this plugin's subcommands. + Annotations: map[string]string{"placeholder": "true"}, RunE: func(cmd *cobra.Command, args []string) error { fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in a future prompt") return nil