// Package projecttype classifies a repository into one of eeco's known // project-type categories and resolves the knowledge-directory set that // `eeco init` scaffolds for it. // // Classification is a four-layer pipeline (see detect.go): a marker-file // scan, a conventional-directory scan, an interactive operator prompt, // and a gated AI fallback. The first three need no AI spend; the fourth // runs only when the caller injects an AIFunc and the operator opts in. // // The category set and per-category scaffold data live in the embedded // catalog (catalog/*.json). The detection heuristics live in this // package as a tuned, testable table, deliberately kept out of the // catalog so the catalog stays an operator-reviewable description of // what each category scaffolds rather than a tuning surface. package projecttype import ( "embed" "encoding/json" "fmt" "slices" ) //go:embed catalog/*.json var catalogFS embed.FS // Category is a project-type identifier. The canonical set is exactly // the filenames under catalog/ (without the .json suffix). type Category string const ( CLI Category = "cli" Library Category = "library" WebApp Category = "webapp" WebAPI Category = "webapi" Fullstack Category = "fullstack" Mobile Category = "mobile" Embedded Category = "embedded" GameDev Category = "gamedev" ML Category = "ml" Infra Category = "infra" Generic Category = "generic" ) // Entry is one catalog record: the scaffold and human/AI-facing data for // a category. type Entry struct { Category Category `json:"category"` Description string `json:"description"` PickWhen string `json:"pick_when"` Dirs []string `json:"dirs"` } // Catalog is the loaded set of category entries, keyed by Category. type Catalog struct { entries map[Category]Entry } // LoadCatalog parses every embedded catalog/*.json file. It errors if a // file is malformed, a category appears twice, an entry has no dirs, or // the generic fallback is absent. func LoadCatalog() (*Catalog, error) { ents, err := catalogFS.ReadDir("catalog") if err != nil { return nil, fmt.Errorf("read catalog dir: %w", err) } c := &Catalog{entries: make(map[Category]Entry, len(ents))} for _, de := range ents { if de.IsDir() { continue } b, err := catalogFS.ReadFile("catalog/" + de.Name()) if err != nil { return nil, fmt.Errorf("read %s: %w", de.Name(), err) } var e Entry if err := json.Unmarshal(b, &e); err != nil { return nil, fmt.Errorf("parse %s: %w", de.Name(), err) } if e.Category == "" { return nil, fmt.Errorf("%s: empty category", de.Name()) } if _, dup := c.entries[e.Category]; dup { return nil, fmt.Errorf("duplicate category %q", e.Category) } if len(e.Dirs) == 0 { return nil, fmt.Errorf("category %q has no dirs", e.Category) } c.entries[e.Category] = e } if _, ok := c.entries[Generic]; !ok { return nil, fmt.Errorf("catalog missing the %q fallback entry", Generic) } return c, nil } // Get returns the entry for cat and whether it is known. func (c *Catalog) Get(cat Category) (Entry, bool) { e, ok := c.entries[cat] return e, ok } // Has reports whether cat is a known category. func (c *Catalog) Has(cat Category) bool { _, ok := c.entries[cat] return ok } // Categories returns every known category in sorted order. func (c *Catalog) Categories() []Category { out := make([]Category, 0, len(c.entries)) for cat := range c.entries { out = append(out, cat) } slices.Sort(out) return out } // DirsFor returns a copy of the scaffold dir-set for cat, or nil if cat // is unknown. func (c *Catalog) DirsFor(cat Category) []string { e, ok := c.entries[cat] if !ok { return nil } return append([]string(nil), e.Dirs...) }