feat: externalize apps/analysis to Gitea repos, add analysis table

- Migration 007: repo_url on apps table + analysis table with FTS5
- Analysis struct, parser, CRUD, validation, hash computation
- Selective purge: remote-only apps/analysis preserved across fn index
- CLI: fn app list/clone/pull, fn analysis list/clone/pull
- search/show/list now include analysis results
- Apps removed from git tracking (content lives in Gitea repos)
- .gitkeep for apps/ and analysis/ dirs
- Bash functions: jupyter analysis pipeline, shell utilities
- Browser domain: CDP functions moved from infra to browser

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 04:23:51 +02:00
parent 8f24157096
commit d7f2c00d7b
111 changed files with 2766 additions and 5043 deletions
+47 -6
View File
@@ -13,6 +13,7 @@ type IndexResult struct {
Functions int
Types int
Apps int
Analysis int
ValidationErrors []string
Errors []string
}
@@ -26,15 +27,11 @@ type IndexResult struct {
// directories (e.g. python/functions/, python/types/).
func Index(db *DB, root string) (*IndexResult, error) {
// Load existing timestamps before purging so we can preserve created_at
oldFuncs, oldTypes, oldApps, err := db.LoadTimestamps()
oldFuncs, oldTypes, oldApps, oldAnalysis, err := db.LoadTimestamps()
if err != nil {
return nil, fmt.Errorf("loading timestamps: %w", err)
}
if err := db.Purge(); err != nil {
return nil, fmt.Errorf("purging database: %w", err)
}
result := &IndexResult{}
// Pass 1: parse everything from all source directories
@@ -42,7 +39,6 @@ func Index(db *DB, root string) (*IndexResult, error) {
var types []*Type
// Directories to scan for functions and types.
// Base dirs + language-specific dirs discovered automatically.
funcDirs := []string{filepath.Join(root, "functions")}
typeDirs := []string{filepath.Join(root, "types")}
@@ -86,6 +82,7 @@ func Index(db *DB, root string) (*IndexResult, error) {
// Parse apps from apps/*/app.md
var apps []*App
localAppIDs := make(map[string]bool)
appsDir := filepath.Join(root, "apps")
if fi, err := os.Stat(appsDir); err == nil && fi.IsDir() {
entries, _ := os.ReadDir(appsDir)
@@ -103,9 +100,39 @@ func Index(db *DB, root string) (*IndexResult, error) {
continue
}
apps = append(apps, a)
localAppIDs[a.ID] = true
}
}
// Parse analysis from analysis/*/analysis.md
var analyses []*Analysis
localAnalysisIDs := make(map[string]bool)
analysisDir := filepath.Join(root, "analysis")
if fi, err := os.Stat(analysisDir); err == nil && fi.IsDir() {
entries, _ := os.ReadDir(analysisDir)
for _, e := range entries {
if !e.IsDir() {
continue
}
analysisMD := filepath.Join(analysisDir, e.Name(), "analysis.md")
if _, err := os.Stat(analysisMD); err != nil {
continue
}
an, err := ParseAnalysisMD(analysisMD, root)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", analysisMD, err))
continue
}
analyses = append(analyses, an)
localAnalysisIDs[an.ID] = true
}
}
// Selective purge: preserve remote-only apps/analysis (have repo_url, not cloned locally)
if err := db.PurgeLocalOnly(localAppIDs, localAnalysisIDs); err != nil {
return nil, fmt.Errorf("purging database: %w", err)
}
// Build known ID sets
knownFunctions := make(map[string]bool, len(functions))
for _, f := range functions {
@@ -161,6 +188,20 @@ func Index(db *DB, root string) (*IndexResult, error) {
result.Apps++
}
for _, an := range analyses {
if verr := ValidateAnalysis(an, knownFunctions, knownTypes); verr != nil {
result.ValidationErrors = append(result.ValidationErrors, verr.Error())
continue
}
an.ContentHash = ComputeAnalysisHash(an)
applyTimestamps(&an.CreatedAt, &an.UpdatedAt, an.ContentHash, oldAnalysis[an.ID], now)
if err := db.InsertAnalysis(an); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", an.ID, err))
continue
}
result.Analysis++
}
return result, nil
}