feat(auto): implement stack detection with csharp/react/go/python heuristics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6852be77ff
commit
8e022bf5a5
@ -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 <plugin> --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)
|
||||
}
|
||||
|
||||
258
internal/plugins/auto/detector.go
Normal file
258
internal/plugins/auto/detector.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user