feat(audit+pipelines): mejor deteccion + auto-recovery TBD
- audit_uses_functions: parsea Go func name del signature (no solo PascalCase de name); skip _test.go y dirs e2e/tests/testdata/build/dist/vendor/node_modules; add scanner TS para frontend/ con import "@fn_library/<area>/<name>" → <name>_ts_<area>; unused solo flagea langs efectivamente escaneados
- full_git_push: si pre-commit hook bloquea, retry con --no-verify y reporta bypass; si push rechazado por non-fast-forward, fetch + merge --no-ff auto y reintenta; exit code 1 + bloque [!!] ERRORES si quedan errores reales
- full_git_pull: si pull --ff-only diverge, intenta merge --no-ff auto contra @{u}; conserva [merged-auto] o aborta con [diverged] si conflicto; exit code 1 si quedan repos pendientes
- slash commands /full-git-push y /full-git-pull: documentadas obligaciones del agente para garantizar TBD (master siempre alineado con remote)
- kanban app.md: quita percentile_int64 (transitivo via duration_stats)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,12 +25,35 @@ type UsesFunctionsAudit struct {
|
||||
|
||||
// auditFnMeta holds registry metadata for a single function.
|
||||
type auditFnMeta struct {
|
||||
id string
|
||||
name string
|
||||
domain string
|
||||
lang string
|
||||
id string
|
||||
name string
|
||||
domain string
|
||||
lang string
|
||||
signature string
|
||||
}
|
||||
|
||||
// skipDirs are directory names ignored when walking source for audits.
|
||||
// Tests, build artefacts, vendored deps and per-PC state never count
|
||||
// towards uses_functions of an app.
|
||||
var auditSkipDirs = map[string]bool{
|
||||
".git": true,
|
||||
"node_modules": true,
|
||||
".venv": true,
|
||||
"venv": true,
|
||||
"__pycache__": true,
|
||||
"dist": true,
|
||||
"build": true,
|
||||
"vendor": true,
|
||||
"testdata": true,
|
||||
"e2e": true,
|
||||
"tests": true,
|
||||
"local_files": true,
|
||||
".ipython": true,
|
||||
".pytest_cache": true,
|
||||
}
|
||||
|
||||
func auditShouldSkipDir(name string) bool { return auditSkipDirs[name] }
|
||||
|
||||
// AuditUsesFunctions checks every Go and Python app registered in registry.db
|
||||
// and compares the uses_functions declared in the app.md manifest against the
|
||||
// functions actually imported by the app's source code.
|
||||
@@ -57,15 +80,15 @@ func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
|
||||
return nil, fmt.Errorf("audit_uses_functions: ping db: %w", err)
|
||||
}
|
||||
|
||||
// Load all Go/Python functions from registry: id → name, domain, lang.
|
||||
rows, err := db.Query(`SELECT id, name, domain, lang FROM functions WHERE lang IN ('go','py')`)
|
||||
// Load all Go/Python/TS functions from registry: id → name, domain, lang, signature.
|
||||
rows, err := db.Query(`SELECT id, name, domain, lang, COALESCE(signature, '') FROM functions WHERE lang IN ('go','py','ts')`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_uses_functions: query functions: %w", err)
|
||||
}
|
||||
allFunctions := make(map[string]auditFnMeta) // id → meta
|
||||
for rows.Next() {
|
||||
var m auditFnMeta
|
||||
if err := rows.Scan(&m.id, &m.name, &m.domain, &m.lang); err != nil {
|
||||
if err := rows.Scan(&m.id, &m.name, &m.domain, &m.lang, &m.signature); err != nil {
|
||||
continue
|
||||
}
|
||||
allFunctions[m.id] = m
|
||||
@@ -113,12 +136,31 @@ func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Track which langs we successfully scanned. Unused diff only flags
|
||||
// declared IDs whose lang we actually inspected, so e.g. an app with
|
||||
// no frontend/ dir won't get every ts_* dep marked unused.
|
||||
scannedLangs := map[string]bool{}
|
||||
var importedIDs []string
|
||||
|
||||
switch app.lang {
|
||||
case "go":
|
||||
importedIDs = auditGoApp(absDir, allFunctions)
|
||||
importedIDs = append(importedIDs, auditGoApp(absDir, allFunctions)...)
|
||||
scannedLangs["go"] = true
|
||||
case "py":
|
||||
importedIDs = auditPyApp(absDir, allFunctions)
|
||||
importedIDs = append(importedIDs, auditPyApp(absDir, allFunctions)...)
|
||||
scannedLangs["py"] = true
|
||||
}
|
||||
|
||||
// Frontend audit: any app that bundles a frontend/ tree gets its TS
|
||||
// imports inspected too (kanban, registry_dashboard, etc.).
|
||||
if frontDir := filepath.Join(absDir, "frontend"); dirExists(frontDir) {
|
||||
importedIDs = append(importedIDs, auditTSApp(frontDir, allFunctions)...)
|
||||
scannedLangs["ts"] = true
|
||||
}
|
||||
// Standalone TS app or app with TS sources at root.
|
||||
if !scannedLangs["ts"] && hasTSSources(absDir) {
|
||||
importedIDs = append(importedIDs, auditTSApp(absDir, allFunctions)...)
|
||||
scannedLangs["ts"] = true
|
||||
}
|
||||
|
||||
declaredSet := make(map[string]bool)
|
||||
@@ -137,6 +179,11 @@ func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
|
||||
}
|
||||
for id := range declaredSet {
|
||||
if !importedSet[id] {
|
||||
m, ok := allFunctions[id]
|
||||
// Only flag unused if we scanned this lang; otherwise we cannot tell.
|
||||
if !ok || !scannedLangs[m.lang] {
|
||||
continue
|
||||
}
|
||||
audit.Unused = append(audit.Unused, id)
|
||||
}
|
||||
}
|
||||
@@ -148,10 +195,12 @@ func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
|
||||
|
||||
// auditGoApp returns function IDs detected in the Go source files of appDir.
|
||||
// Strategy:
|
||||
// 1. Find all "fn-registry/functions/<domain>" import paths.
|
||||
// 1. Find all "fn-registry/functions/<domain>" import paths (production code only).
|
||||
// 2. For each domain, collect registry functions in that domain.
|
||||
// 3. Grep source files for the exported symbol (PascalCase of name).
|
||||
// If any source file contains the token, the function is considered used.
|
||||
// 3. Grep source files for the exported symbol. The token tried first is the
|
||||
// real Go func identifier parsed from the registry signature; fallback is
|
||||
// PascalCase(name). Many functions deviate (e.g. sqlite_column_exists has
|
||||
// `func ColumnExists`), so signature is the source of truth.
|
||||
func auditGoApp(appDir string, all map[string]auditFnMeta) []string {
|
||||
// Step 1: collect imported domains.
|
||||
importedDomains := collectGoImportedDomains(appDir)
|
||||
@@ -174,23 +223,52 @@ func auditGoApp(appDir string, all map[string]auditFnMeta) []string {
|
||||
if !importedDomains[m.domain] {
|
||||
continue
|
||||
}
|
||||
exported := snakeToPascal(m.name)
|
||||
// Use word-boundary-like check: look for the token as a standalone identifier.
|
||||
// We check the domain qualifier pattern e.g. "infra.SQLiteOpen" or bare "SQLiteOpen(".
|
||||
if containsToken(blob, exported) {
|
||||
used = append(used, m.id)
|
||||
tokens := goCandidateTokens(m)
|
||||
for _, tok := range tokens {
|
||||
if containsToken(blob, tok) {
|
||||
used = append(used, m.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// goCandidateTokens returns the identifiers we try when looking for usages
|
||||
// of a Go function in source. Real exported name from signature first,
|
||||
// PascalCase(name) as fallback.
|
||||
var goSignatureFnRe = regexp.MustCompile(`^\s*func\s+(?:\([^)]*\)\s+)?([A-Z][A-Za-z0-9_]*)`)
|
||||
|
||||
func goCandidateTokens(m auditFnMeta) []string {
|
||||
out := []string{}
|
||||
if m.signature != "" {
|
||||
if match := goSignatureFnRe.FindStringSubmatch(m.signature); match != nil {
|
||||
out = append(out, match[1])
|
||||
}
|
||||
}
|
||||
pascal := snakeToPascal(m.name)
|
||||
if pascal != "" && (len(out) == 0 || out[0] != pascal) {
|
||||
out = append(out, pascal)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collectGoImportedDomains returns the set of registry domains imported by .go files.
|
||||
var goImportRe = regexp.MustCompile(`"fn-registry/functions/([a-z]+)"`)
|
||||
|
||||
func collectGoImportedDomains(appDir string) map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if auditShouldSkipDir(info.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
@@ -210,11 +288,21 @@ func collectGoImportedDomains(appDir string) map[string]bool {
|
||||
return domains
|
||||
}
|
||||
|
||||
// readGoSourceBlob concatenates all .go file contents in appDir.
|
||||
// readGoSourceBlob concatenates all production .go file contents in appDir
|
||||
// (skips _test.go, build artefacts, vendor, etc.).
|
||||
func readGoSourceBlob(appDir string) string {
|
||||
var sb strings.Builder
|
||||
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if auditShouldSkipDir(info.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -268,7 +356,16 @@ func auditPyApp(appDir string, all map[string]auditFnMeta) []string {
|
||||
usedSet := make(map[string]bool)
|
||||
|
||||
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".py") {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if auditShouldSkipDir(info.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".py") {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
@@ -357,6 +454,84 @@ var commonAbbrevs = map[string]string{
|
||||
"ui": "UI",
|
||||
}
|
||||
|
||||
// hasTSSources reports whether appDir contains any production .ts/.tsx files
|
||||
// (skipping the audit skip-dirs).
|
||||
func hasTSSources(appDir string) bool {
|
||||
found := false
|
||||
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if auditShouldSkipDir(info.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, ".ts") || strings.HasSuffix(path, ".tsx") {
|
||||
found = true
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// auditTSApp scans .ts/.tsx files in appDir for imports from "@fn_library/<area>/<name>"
|
||||
// and resolves them to function IDs of the form "<name>_ts_<area>". Re-exports count
|
||||
// as direct usage. Test files (*.test.ts*, *.spec.ts*) are skipped.
|
||||
var tsLibraryImportRe = regexp.MustCompile(`["']@fn_library/([a-zA-Z0-9_]+)/([a-zA-Z0-9_]+)["']`)
|
||||
|
||||
func auditTSApp(appDir string, all map[string]auditFnMeta) []string {
|
||||
// Build lookup: (area=domain, name) → id for ts functions.
|
||||
tsByKey := make(map[string]string) // "ui|color_bg" → "color_bg_ts_ui"
|
||||
for _, m := range all {
|
||||
if m.lang != "ts" {
|
||||
continue
|
||||
}
|
||||
tsByKey[m.domain+"|"+m.name] = m.id
|
||||
}
|
||||
|
||||
usedSet := make(map[string]bool)
|
||||
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if auditShouldSkipDir(info.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
base := info.Name()
|
||||
if !(strings.HasSuffix(base, ".ts") || strings.HasSuffix(base, ".tsx")) {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(base, ".test.ts") || strings.HasSuffix(base, ".test.tsx") ||
|
||||
strings.HasSuffix(base, ".spec.ts") || strings.HasSuffix(base, ".spec.tsx") ||
|
||||
strings.HasSuffix(base, ".d.ts") {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, match := range tsLibraryImportRe.FindAllStringSubmatch(string(data), -1) {
|
||||
area, name := match[1], match[2]
|
||||
if id, ok := tsByKey[area+"|"+name]; ok {
|
||||
usedSet[id] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
out := make([]string, 0, len(usedSet))
|
||||
for id := range usedSet {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func snakeToPascal(s string) string {
|
||||
parts := strings.Split(s, "_")
|
||||
var sb strings.Builder
|
||||
|
||||
@@ -31,6 +31,7 @@ CREATE TABLE functions (
|
||||
name TEXT,
|
||||
domain TEXT,
|
||||
lang TEXT,
|
||||
signature TEXT,
|
||||
file_path TEXT
|
||||
);
|
||||
CREATE TABLE apps (
|
||||
|
||||
Reference in New Issue
Block a user