feat: soporte projects y vaults en registry

Añade tablas projects y vaults a registry.db con FTS5, modelos Go,
parser de project.md y vault.yaml, CRUD completo en store, hashing
determinista, validación, y soporte en el indexer para escanear
projects/{name}/ con sus apps, analysis y vaults anidados.
Migration 010 crea las tablas, triggers FTS5, y columna project_id
en apps/analysis. El indexer preserva records remotos (repo_url) al
reindexar, igual que apps/analysis.
This commit is contained in:
2026-04-12 17:29:41 +02:00
parent 1a3e77b0d5
commit 54e62ecb91
8 changed files with 647 additions and 19 deletions
+114
View File
@@ -103,6 +103,27 @@ type rawAnalysis struct {
RepoURL string `yaml:"repo_url"`
}
// rawProject mirrors the YAML frontmatter of a project .md file.
type rawProject struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Tags []string `yaml:"tags"`
RepoURL string `yaml:"repo_url"`
}
// rawVaultFile mirrors the YAML of a vault.yaml manifest file.
type rawVaultFile struct {
Vaults []rawVaultEntry `yaml:"vaults"`
}
// rawVaultEntry describes a single vault in vault.yaml.
type rawVaultEntry struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Path string `yaml:"path"`
Tags []string `yaml:"tags"`
}
// extractFrontmatter splits a .md file into YAML frontmatter and body.
func extractFrontmatter(data []byte) ([]byte, []byte, error) {
content := data
@@ -356,6 +377,99 @@ func ParseAnalysisMD(path string, root string) (*Analysis, error) {
return an, nil
}
// ParseProjectMD parses a project .md file into a Project.
func ParseProjectMD(path string, root string) (*Project, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
fm, body, err := extractFrontmatter(data)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
var raw rawProject
if err := yaml.Unmarshal(fm, &raw); err != nil {
return nil, fmt.Errorf("parsing YAML in %s: %w", path, err)
}
if raw.Name == "" {
return nil, fmt.Errorf("%s: name is required", path)
}
if raw.Description == "" {
return nil, fmt.Errorf("%s: description is required", path)
}
sections := extractSections(body)
p := &Project{
ID: raw.Name,
Name: raw.Name,
Description: raw.Description,
Tags: raw.Tags,
RepoURL: raw.RepoURL,
DirPath: filepath.Join("projects", raw.Name),
Documentation: sections.documentation,
Notes: sections.notes,
}
return p, nil
}
// ParseVaultYAML parses a vault.yaml manifest into a slice of Vaults.
// projectID is the owning project ID, or "" for registry-level vaults.
// vaultsDir is the directory containing vault.yaml (used to detect symlinks).
func ParseVaultYAML(path string, projectID string, vaultsDir string) ([]*Vault, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var raw rawVaultFile
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing YAML in %s: %w", path, err)
}
var vaults []*Vault
for _, rv := range raw.Vaults {
if rv.Name == "" {
continue
}
suffix := projectID
if suffix == "" {
suffix = "registry"
}
id := rv.Name + "_" + suffix
// Detect if the vault entry on disk is a symlink
isSymlink := false
vaultPath := rv.Path
entryPath := filepath.Join(vaultsDir, rv.Name)
if fi, err := os.Lstat(entryPath); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
isSymlink = true
if target, err := os.Readlink(entryPath); err == nil && vaultPath == "" {
vaultPath = target
}
}
}
vaults = append(vaults, &Vault{
ID: id,
Name: rv.Name,
ProjectID: projectID,
Description: rv.Description,
Path: vaultPath,
Symlink: isSymlink,
Tags: rv.Tags,
})
}
return vaults, nil
}
// bodySections holds the extracted sections from a .md body.
type bodySections struct {
example string // content under ## Ejemplo