feat(monorepo): import repo-memory runtime
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ai-workflow-skill/packages/repo-memory-runtime/internal/brief"
|
||||
"ai-workflow-skill/packages/repo-memory-runtime/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "init":
|
||||
if err := runInit(os.Args[2:]); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "add":
|
||||
if err := runAdd(os.Args[2:]); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "ingest":
|
||||
if err := runIngest(os.Args[2:]); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "search":
|
||||
if err := runSearch(os.Args[2:]); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "list":
|
||||
if err := runList(os.Args[2:]); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "events":
|
||||
if err := runEvents(os.Args[2:]); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "link":
|
||||
if err := runLink(os.Args[2:]); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "verify":
|
||||
if err := runVerify(os.Args[2:]); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "repos":
|
||||
if err := runRepos(os.Args[2:]); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
type stringSliceFlag []string
|
||||
|
||||
func (s *stringSliceFlag) String() string {
|
||||
return strings.Join(*s, ",")
|
||||
}
|
||||
|
||||
func (s *stringSliceFlag) Set(value string) error {
|
||||
*s = append(*s, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runInit(args []string) error {
|
||||
fs := flag.NewFlagSet("init", flag.ContinueOnError)
|
||||
dbPath := fs.String("db", "briefs.db", "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, err := store.Open(*dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if err := st.Init(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("initialized %s\n", *dbPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runIngest(args []string) error {
|
||||
fs := flag.NewFlagSet("ingest", flag.ContinueOnError)
|
||||
dbPath := fs.String("db", "briefs.db", "SQLite database path")
|
||||
repoPath := fs.String("repo", "", "Repository root")
|
||||
scanPath := fs.String("path", "docs/ai", "Relative path under repo to scan for markdown")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(*repoPath) == "" {
|
||||
return fmt.Errorf("--repo is required")
|
||||
}
|
||||
|
||||
absRepo, err := filepath.Abs(*repoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, err := store.Open(*dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if err := st.Init(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gitState := detectGitState(absRepo)
|
||||
if _, err := st.UpsertRepo(context.Background(), store.RepoState{
|
||||
RootPath: absRepo,
|
||||
Name: filepath.Base(absRepo),
|
||||
VCS: "git",
|
||||
LastSeenBranch: gitState.branch,
|
||||
LastSeenCommit: gitState.commit,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
docs, err := brief.LoadRepo(absRepo, *scanPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return fmt.Errorf("no markdown files found under %s", filepath.Join(absRepo, *scanPath))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, doc := range docs {
|
||||
if err := st.ImportDocument(ctx, doc, store.RepoState{
|
||||
RootPath: absRepo,
|
||||
Name: filepath.Base(absRepo),
|
||||
VCS: "git",
|
||||
LastSeenBranch: gitState.branch,
|
||||
LastSeenCommit: gitState.commit,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("ingest %s: %w", doc.DocPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("ingested %d docs from %s\n", len(docs), absRepo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAdd(args []string) error {
|
||||
fs := flag.NewFlagSet("add", flag.ContinueOnError)
|
||||
dbPath := fs.String("db", "briefs.db", "SQLite database path")
|
||||
repoPath := fs.String("repo", "", "Repository root")
|
||||
kind := fs.String("kind", "", "Knowledge kind, e.g. term|chain|danger")
|
||||
key := fs.String("key", "", "Stable entry key")
|
||||
title := fs.String("title", "", "Display title")
|
||||
summary := fs.String("summary", "", "Short summary")
|
||||
detail := fs.String("detail", "", "Detailed markdown text")
|
||||
status := fs.String("status", "draft", "Entry status")
|
||||
confidence := fs.Float64("confidence", 0.7, "Confidence from 0 to 1")
|
||||
scopeBranch := fs.String("scope-branch", "", "Optional branch scope")
|
||||
scopeModule := fs.String("scope-module", "", "Optional module scope")
|
||||
scopePath := fs.String("scope-path-prefix", "", "Optional path prefix scope")
|
||||
sourcePath := fs.String("source-path", "", "Main evidence path")
|
||||
sourceLine := fs.Int("source-line", 0, "Main evidence line")
|
||||
var aliases stringSliceFlag
|
||||
var deps stringSliceFlag
|
||||
fs.Var(&aliases, "alias", "Alias for this entry (repeatable)")
|
||||
fs.Var(&deps, "dep", "Dependency in type:locator[:hard|soft] format (repeatable)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(*repoPath) == "" {
|
||||
return fmt.Errorf("--repo is required")
|
||||
}
|
||||
|
||||
absRepo, err := filepath.Abs(*repoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, err := store.Open(*dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if err := st.Init(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gitState := detectGitState(absRepo)
|
||||
if _, err := st.UpsertRepo(context.Background(), store.RepoState{
|
||||
RootPath: absRepo,
|
||||
Name: filepath.Base(absRepo),
|
||||
VCS: "git",
|
||||
LastSeenBranch: gitState.branch,
|
||||
LastSeenCommit: gitState.commit,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
input := store.EntryInput{
|
||||
RepoRoot: absRepo,
|
||||
Kind: *kind,
|
||||
Key: *key,
|
||||
Title: *title,
|
||||
Summary: *summary,
|
||||
DetailMD: *detail,
|
||||
Status: *status,
|
||||
Confidence: *confidence,
|
||||
ScopeBranch: *scopeBranch,
|
||||
ScopeModule: *scopeModule,
|
||||
ScopePathPrefix: *scopePath,
|
||||
SourcePath: relativize(absRepo, *sourcePath),
|
||||
SourceLine: *sourceLine,
|
||||
VerifiedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
VerifiedOnCommit: gitState.commit,
|
||||
Aliases: aliases,
|
||||
Dependencies: parseDeps(absRepo, deps),
|
||||
}
|
||||
entryID, err := st.UpsertEntry(context.Background(), input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("upserted entry %d (%s:%s)\n", entryID, input.Kind, input.Key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSearch(args []string) error {
|
||||
fs := flag.NewFlagSet("search", flag.ContinueOnError)
|
||||
dbPath := fs.String("db", "briefs.db", "SQLite database path")
|
||||
query := fs.String("query", "", "Search query")
|
||||
repo := fs.String("repo", "", "Optional repo path filter (substring match)")
|
||||
limit := fs.Int("limit", 10, "Result limit")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(*query) == "" {
|
||||
return fmt.Errorf("--query is required")
|
||||
}
|
||||
|
||||
st, err := store.Open(*dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
results, err := st.Search(context.Background(), store.SearchParams{
|
||||
Query: *query,
|
||||
RepoFilter: *repo,
|
||||
Limit: *limit,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
fmt.Println("no results")
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, r := range results {
|
||||
fmt.Printf("%d. [%s] %s:%s [%s]\n", i+1, filepath.Base(r.RepoPath), r.Kind, r.Key, r.Status)
|
||||
fmt.Printf(" %s\n", r.Title)
|
||||
fmt.Printf(" %s\n", r.Snippet)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRepos(args []string) error {
|
||||
fs := flag.NewFlagSet("repos", flag.ContinueOnError)
|
||||
dbPath := fs.String("db", "briefs.db", "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, err := store.Open(*dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
repos, err := st.ListRepos(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
fmt.Println("no repos")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
fmt.Printf("- %s (%d entries, updated %s)\n", repo.Path, repo.EntryCount, repo.UpdatedAt.Format(time.RFC3339))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runList(args []string) error {
|
||||
fs := flag.NewFlagSet("list", flag.ContinueOnError)
|
||||
dbPath := fs.String("db", "briefs.db", "SQLite database path")
|
||||
repo := fs.String("repo", "", "Optional repo path filter (substring match)")
|
||||
kind := fs.String("kind", "", "Optional knowledge kind filter")
|
||||
status := fs.String("status", "", "Optional status filter")
|
||||
limit := fs.Int("limit", 20, "Result limit")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, err := store.Open(*dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
items, err := st.ListEntries(context.Background(), store.ListParams{
|
||||
RepoFilter: *repo,
|
||||
Kind: *kind,
|
||||
Status: *status,
|
||||
Limit: *limit,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
fmt.Println("no entries")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
fmt.Printf("- #%d [%s] %s:%s [%s]\n", item.ID, filepath.Base(item.RepoPath), item.Kind, item.Key, item.Status)
|
||||
fmt.Printf(" %s\n", item.Summary)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEvents(args []string) error {
|
||||
fs := flag.NewFlagSet("events", flag.ContinueOnError)
|
||||
dbPath := fs.String("db", "briefs.db", "SQLite database path")
|
||||
id := fs.Int64("id", 0, "Entry id")
|
||||
repo := fs.String("repo", "", "Repo root when resolving by kind/key")
|
||||
kind := fs.String("kind", "", "Knowledge kind when resolving by kind/key")
|
||||
key := fs.String("key", "", "Knowledge key when resolving by kind/key")
|
||||
limit := fs.Int("limit", 20, "Result limit")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, err := store.Open(*dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
ref, err := buildEntryRef(*id, *repo, *kind, *key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := st.ResolveEntry(context.Background(), ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events, err := st.ListEvents(context.Background(), entry.ID, *limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
fmt.Println("no events")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s:%s [%s] #%d\n", entry.Kind, entry.Key, entry.Status, entry.ID)
|
||||
for _, ev := range events {
|
||||
line := fmt.Sprintf("- %s %s", ev.CreatedAt.Format(time.RFC3339), ev.EventType)
|
||||
if ev.FromStatus != "" || ev.ToStatus != "" {
|
||||
line += fmt.Sprintf(" (%s -> %s)", emptyDash(ev.FromStatus), emptyDash(ev.ToStatus))
|
||||
}
|
||||
fmt.Println(line)
|
||||
if ev.Reason != "" {
|
||||
fmt.Printf(" reason: %s\n", ev.Reason)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runLink(args []string) error {
|
||||
fs := flag.NewFlagSet("link", flag.ContinueOnError)
|
||||
dbPath := fs.String("db", "briefs.db", "SQLite database path")
|
||||
fromID := fs.Int64("from-id", 0, "From entry id")
|
||||
toID := fs.Int64("to-id", 0, "To entry id")
|
||||
relation := fs.String("relation", "", "Link relation")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, err := store.Open(*dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if err := st.AddLink(context.Background(), *fromID, *toID, *relation); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("linked #%d -[%s]-> #%d\n", *fromID, *relation, *toID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runVerify(args []string) error {
|
||||
fs := flag.NewFlagSet("verify", flag.ContinueOnError)
|
||||
dbPath := fs.String("db", "briefs.db", "SQLite database path")
|
||||
repo := fs.String("repo", "", "Optional repo root to verify; if omitted, verify all known repos")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, err := store.Open(*dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
var repoRoots []string
|
||||
if strings.TrimSpace(*repo) != "" {
|
||||
absRepo, err := filepath.Abs(*repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repoRoots = []string{absRepo}
|
||||
} else {
|
||||
repos, err := st.ListRepos(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, repoInfo := range repos {
|
||||
repoRoots = append(repoRoots, repoInfo.Path)
|
||||
}
|
||||
}
|
||||
if len(repoRoots) == 0 {
|
||||
fmt.Println("no repos")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, repoRoot := range repoRoots {
|
||||
gitState := detectGitState(repoRoot)
|
||||
if gitState.commit == "" {
|
||||
fmt.Printf("%s: skipped (not a git repo or no HEAD)\n", repoRoot)
|
||||
continue
|
||||
}
|
||||
if _, err := st.UpsertRepo(context.Background(), store.RepoState{
|
||||
RootPath: repoRoot,
|
||||
Name: filepath.Base(repoRoot),
|
||||
VCS: "git",
|
||||
LastSeenBranch: gitState.branch,
|
||||
LastSeenCommit: gitState.commit,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
candidates, err := st.ListVerifyCandidates(context.Background(), repoRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changedCount := 0
|
||||
staleCount := 0
|
||||
for _, candidate := range candidates {
|
||||
nextStatus, reason, err := verifyCandidate(repoRoot, candidate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify %s:%s: %w", candidate.Kind, candidate.Key, err)
|
||||
}
|
||||
if nextStatus != candidate.Status {
|
||||
switch nextStatus {
|
||||
case "needs_review":
|
||||
changedCount++
|
||||
case "stale":
|
||||
staleCount++
|
||||
}
|
||||
}
|
||||
if err := st.ApplyVerificationResult(context.Background(), candidate.ID, gitState.commit, nextStatus, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s: verified %d entries, %d downgraded, %d stale\n", repoRoot, len(candidates), changedCount, staleCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `briefdb: repo memory CLI
|
||||
|
||||
Usage:
|
||||
briefdb init --db briefs.db
|
||||
briefdb add --db briefs.db --repo /path/to/repo --kind term --key AITask --summary "..."
|
||||
briefdb ingest --db briefs.db --repo /path/to/repo [--path docs/ai]
|
||||
briefdb search --db briefs.db --query "actionCode fill" [--repo zeus]
|
||||
briefdb list --db briefs.db [--repo zeus] [--kind term] [--status confirmed]
|
||||
briefdb events --db briefs.db --id 1
|
||||
briefdb link --db briefs.db --from-id 1 --to-id 2 --relation related_to
|
||||
briefdb verify --db briefs.db [--repo /path/to/repo]
|
||||
briefdb repos --db briefs.db
|
||||
`)
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
type gitState struct {
|
||||
branch string
|
||||
commit string
|
||||
}
|
||||
|
||||
func detectGitState(repo string) gitState {
|
||||
return gitState{
|
||||
branch: strings.TrimSpace(run(repo, "git", "-C", repo, "rev-parse", "--abbrev-ref", "HEAD")),
|
||||
commit: strings.TrimSpace(run(repo, "git", "-C", repo, "rev-parse", "HEAD")),
|
||||
}
|
||||
}
|
||||
|
||||
func run(dir string, name string, args ...string) string {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func parseDeps(repoRoot string, values []string) []store.DependencyInput {
|
||||
var deps []store.DependencyInput
|
||||
for _, raw := range values {
|
||||
parts := strings.Split(raw, ":")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
dep := store.DependencyInput{
|
||||
Type: strings.TrimSpace(parts[0]),
|
||||
Locator: strings.TrimSpace(parts[1]),
|
||||
IsHard: true,
|
||||
}
|
||||
if dep.Type == "file" || dep.Type == "dir" || dep.Type == "glob" {
|
||||
dep.Locator = relativize(repoRoot, dep.Locator)
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
mode := strings.ToLower(strings.TrimSpace(parts[2]))
|
||||
if mode == "soft" {
|
||||
dep.IsHard = false
|
||||
}
|
||||
}
|
||||
if dep.Type != "" && dep.Locator != "" {
|
||||
deps = append(deps, dep)
|
||||
}
|
||||
}
|
||||
return deps
|
||||
}
|
||||
|
||||
func relativize(repoRoot, maybePath string) string {
|
||||
if strings.TrimSpace(maybePath) == "" {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(maybePath) {
|
||||
return filepath.ToSlash(maybePath)
|
||||
}
|
||||
rel, err := filepath.Rel(repoRoot, maybePath)
|
||||
if err != nil {
|
||||
return filepath.ToSlash(maybePath)
|
||||
}
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return filepath.ToSlash(maybePath)
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func buildEntryRef(id int64, repo, kind, key string) (store.EntryRef, error) {
|
||||
if id > 0 {
|
||||
return store.EntryRef{ID: id}, nil
|
||||
}
|
||||
if strings.TrimSpace(repo) == "" || strings.TrimSpace(kind) == "" || strings.TrimSpace(key) == "" {
|
||||
return store.EntryRef{}, fmt.Errorf("either --id or --repo+--kind+--key is required")
|
||||
}
|
||||
absRepo, err := filepath.Abs(repo)
|
||||
if err != nil {
|
||||
return store.EntryRef{}, err
|
||||
}
|
||||
return store.EntryRef{RepoRoot: absRepo, Kind: kind, Key: key}, nil
|
||||
}
|
||||
|
||||
func verifyCandidate(repoRoot string, candidate store.VerifyCandidate) (string, string, error) {
|
||||
if strings.TrimSpace(candidate.VerifiedOnCommit) == "" {
|
||||
return "needs_review", "missing verified_on_commit", nil
|
||||
}
|
||||
|
||||
var paths []string
|
||||
for _, dep := range candidate.Dependencies {
|
||||
if !dep.IsHard {
|
||||
continue
|
||||
}
|
||||
switch dep.Type {
|
||||
case "file", "dir", "glob":
|
||||
paths = append(paths, dep.Locator)
|
||||
if dep.Type == "file" {
|
||||
absPath := dep.Locator
|
||||
if !filepath.IsAbs(absPath) {
|
||||
absPath = filepath.Join(repoRoot, dep.Locator)
|
||||
}
|
||||
if _, err := os.Stat(absPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stale", "hard dependency missing: " + dep.Locator, nil
|
||||
}
|
||||
return candidate.Status, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return candidate.Status, "", nil
|
||||
}
|
||||
|
||||
changed, err := hasCommittedChanges(repoRoot, candidate.VerifiedOnCommit, paths)
|
||||
if err != nil {
|
||||
return candidate.Status, "", err
|
||||
}
|
||||
dirty, err := hasWorkingTreeChanges(repoRoot, paths)
|
||||
if err != nil {
|
||||
return candidate.Status, "", err
|
||||
}
|
||||
if changed || dirty {
|
||||
return "needs_review", "dependency paths changed since verification", nil
|
||||
}
|
||||
return candidate.Status, "", nil
|
||||
}
|
||||
|
||||
func hasCommittedChanges(repoRoot, fromCommit string, paths []string) (bool, error) {
|
||||
args := append([]string{"-C", repoRoot, "diff", "--name-only", fromCommit + "..HEAD", "--"}, paths...)
|
||||
out, err := exec.Command("git", args...).Output()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(string(out)) != "", nil
|
||||
}
|
||||
|
||||
func hasWorkingTreeChanges(repoRoot string, paths []string) (bool, error) {
|
||||
args := append([]string{"-C", repoRoot, "status", "--porcelain", "--"}, paths...)
|
||||
out, err := exec.Command("git", args...).Output()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(string(out)) != "", nil
|
||||
}
|
||||
|
||||
func emptyDash(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "-"
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"ai-workflow-skill/packages/repo-memory-runtime/internal/store"
|
||||
)
|
||||
|
||||
func TestVerifyCandidateDetectsFileChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := t.TempDir()
|
||||
runCmd(t, repo, "git", "init")
|
||||
runCmd(t, repo, "git", "config", "user.email", "test@example.com")
|
||||
runCmd(t, repo, "git", "config", "user.name", "Tester")
|
||||
|
||||
file := filepath.Join(repo, "foo.txt")
|
||||
if err := os.WriteFile(file, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runCmd(t, repo, "git", "add", ".")
|
||||
runCmd(t, repo, "git", "commit", "-m", "init")
|
||||
commit := stringsTrim(runOut(t, repo, "git", "rev-parse", "HEAD"))
|
||||
|
||||
if err := os.WriteFile(file, []byte("changed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, reason, err := verifyCandidate(repo, store.VerifyCandidate{
|
||||
Status: "confirmed",
|
||||
VerifiedOnCommit: commit,
|
||||
Dependencies: []store.DependencyInput{
|
||||
{Type: "file", Locator: "foo.txt", IsHard: true},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "needs_review" {
|
||||
t.Fatalf("status = %q, want needs_review", status)
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatal("expected downgrade reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCandidateMarksMissingDependencyStale(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := t.TempDir()
|
||||
runCmd(t, repo, "git", "init")
|
||||
runCmd(t, repo, "git", "config", "user.email", "test@example.com")
|
||||
runCmd(t, repo, "git", "config", "user.name", "Tester")
|
||||
runCmd(t, repo, "git", "commit", "--allow-empty", "-m", "init")
|
||||
commit := stringsTrim(runOut(t, repo, "git", "rev-parse", "HEAD"))
|
||||
|
||||
status, reason, err := verifyCandidate(repo, store.VerifyCandidate{
|
||||
Status: "confirmed",
|
||||
VerifiedOnCommit: commit,
|
||||
Dependencies: []store.DependencyInput{
|
||||
{Type: "file", Locator: "missing.txt", IsHard: true},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "stale" {
|
||||
t.Fatalf("status = %q, want stale", status)
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatal("expected stale reason")
|
||||
}
|
||||
}
|
||||
|
||||
func runCmd(t *testing.T, dir string, name string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s %v failed: %v\n%s", name, args, err, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func runOut(t *testing.T, dir string, name string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("%s %v failed: %v", name, args, err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func stringsTrim(value string) string {
|
||||
for len(value) > 0 && (value[len(value)-1] == '\n' || value[len(value)-1] == '\r' || value[len(value)-1] == ' ') {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
module ai-workflow-skill/packages/repo-memory-runtime
|
||||
|
||||
go 1.26
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.37
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.37
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package brief
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Document struct {
|
||||
RepoPath string
|
||||
DocPath string
|
||||
Kind string
|
||||
Title string
|
||||
Hash string
|
||||
Metadata map[string]string
|
||||
Sections []Section
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
Heading string
|
||||
Level int
|
||||
Ordinal int
|
||||
Body string
|
||||
}
|
||||
|
||||
func LoadRepo(repoPath, scanPath string) ([]Document, error) {
|
||||
root := filepath.Join(repoPath, scanPath)
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a directory", root)
|
||||
}
|
||||
|
||||
var docs []Document
|
||||
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(path)) != ".md" {
|
||||
return nil
|
||||
}
|
||||
|
||||
doc, parseErr := ParseFile(repoPath, path)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(docs, func(i, j int) bool {
|
||||
return docs[i].DocPath < docs[j].DocPath
|
||||
})
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
func ParseFile(repoPath, path string) (Document, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(repoPath, path)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
|
||||
meta, body := splitFrontMatter(string(content))
|
||||
sections, title := parseSections(body, fallbackTitle(path))
|
||||
if len(sections) == 0 {
|
||||
sections = []Section{{
|
||||
Heading: fallbackTitle(path),
|
||||
Level: 1,
|
||||
Ordinal: 1,
|
||||
Body: strings.TrimSpace(body),
|
||||
}}
|
||||
}
|
||||
if metaTitle := strings.TrimSpace(meta["title"]); metaTitle != "" {
|
||||
title = metaTitle
|
||||
}
|
||||
if title == "" {
|
||||
title = fallbackTitle(path)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(content)
|
||||
return Document{
|
||||
RepoPath: repoPath,
|
||||
DocPath: filepath.ToSlash(relPath),
|
||||
Kind: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)),
|
||||
Title: title,
|
||||
Hash: hex.EncodeToString(sum[:]),
|
||||
Metadata: meta,
|
||||
Sections: sections,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func splitFrontMatter(content string) (map[string]string, string) {
|
||||
meta := map[string]string{}
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) < 3 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return meta, content
|
||||
}
|
||||
|
||||
end := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if strings.TrimSpace(lines[i]) == "---" {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
parts := strings.SplitN(lines[i], ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.Trim(strings.TrimSpace(parts[1]), `"'`)
|
||||
if key != "" {
|
||||
meta[key] = val
|
||||
}
|
||||
}
|
||||
if end == -1 {
|
||||
return map[string]string{}, content
|
||||
}
|
||||
|
||||
return meta, strings.Join(lines[end+1:], "\n")
|
||||
}
|
||||
|
||||
func parseSections(body, fallback string) ([]Section, string) {
|
||||
lines := strings.Split(body, "\n")
|
||||
var sections []Section
|
||||
var title string
|
||||
|
||||
currentHeading := "Overview"
|
||||
currentLevel := 1
|
||||
currentBody := make([]string, 0, len(lines))
|
||||
ordinal := 0
|
||||
|
||||
flush := func() {
|
||||
text := strings.TrimSpace(strings.Join(currentBody, "\n"))
|
||||
if text == "" && ordinal > 0 {
|
||||
currentBody = currentBody[:0]
|
||||
return
|
||||
}
|
||||
ordinal++
|
||||
heading := currentHeading
|
||||
if heading == "" {
|
||||
heading = fallback
|
||||
}
|
||||
sections = append(sections, Section{
|
||||
Heading: heading,
|
||||
Level: currentLevel,
|
||||
Ordinal: ordinal,
|
||||
Body: text,
|
||||
})
|
||||
currentBody = currentBody[:0]
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
level, heading, ok := parseHeading(trimmed)
|
||||
if !ok {
|
||||
currentBody = append(currentBody, line)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.TrimSpace(strings.Join(currentBody, "\n")) != "" || ordinal > 0 {
|
||||
flush()
|
||||
}
|
||||
currentHeading = heading
|
||||
currentLevel = level
|
||||
if title == "" && level == 1 {
|
||||
title = heading
|
||||
}
|
||||
}
|
||||
|
||||
if len(currentBody) > 0 || ordinal == 0 {
|
||||
flush()
|
||||
}
|
||||
|
||||
return sections, title
|
||||
}
|
||||
|
||||
func parseHeading(line string) (int, string, bool) {
|
||||
if line == "" || !strings.HasPrefix(line, "#") {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
level := 0
|
||||
for level < len(line) && line[level] == '#' {
|
||||
level++
|
||||
}
|
||||
if level == 0 || level > 6 {
|
||||
return 0, "", false
|
||||
}
|
||||
if level >= len(line) || line[level] != ' ' {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
heading := strings.TrimSpace(line[level:])
|
||||
if heading == "" {
|
||||
return 0, "", false
|
||||
}
|
||||
return level, heading, true
|
||||
}
|
||||
|
||||
func fallbackTitle(path string) string {
|
||||
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
base = strings.ReplaceAll(base, "-", " ")
|
||||
base = strings.ReplaceAll(base, "_", " ")
|
||||
return strings.TrimSpace(base)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package brief
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := t.TempDir()
|
||||
path := filepath.Join(repo, "docs", "ai")
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
file := filepath.Join(path, "repo-brief.md")
|
||||
content := strings.TrimSpace(`
|
||||
---
|
||||
title: Zeus Repo Brief
|
||||
repo: zeus
|
||||
---
|
||||
|
||||
# Repo Brief
|
||||
|
||||
Intro text.
|
||||
|
||||
## Module Map
|
||||
|
||||
- app/app
|
||||
- gateway
|
||||
|
||||
## Danger Zones
|
||||
|
||||
- shared libs
|
||||
`)
|
||||
if err := os.WriteFile(file, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
doc, err := ParseFile(repo, file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got, want := doc.Title, "Zeus Repo Brief"; got != want {
|
||||
t.Fatalf("title = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := doc.DocPath, "docs/ai/repo-brief.md"; got != want {
|
||||
t.Fatalf("doc path = %q, want %q", got, want)
|
||||
}
|
||||
if len(doc.Sections) != 3 {
|
||||
t.Fatalf("sections = %d, want 3", len(doc.Sections))
|
||||
}
|
||||
if got, want := doc.Sections[1].Heading, "Module Map"; got != want {
|
||||
t.Fatalf("section heading = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ai-workflow-skill/packages/repo-memory-runtime/internal/brief"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
root_path TEXT NOT NULL UNIQUE,
|
||||
vcs TEXT NOT NULL DEFAULT 'git',
|
||||
default_branch TEXT,
|
||||
last_seen_branch TEXT,
|
||||
last_seen_commit TEXT,
|
||||
last_sync_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
detail_md TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0.7,
|
||||
scope_branch TEXT,
|
||||
scope_module TEXT,
|
||||
scope_path_prefix TEXT,
|
||||
source_path TEXT,
|
||||
source_line INTEGER,
|
||||
verified_at TEXT,
|
||||
verified_on_commit TEXT,
|
||||
last_checked_at TEXT,
|
||||
last_checked_commit TEXT,
|
||||
stale_reason TEXT,
|
||||
superseded_by INTEGER REFERENCES knowledge_entries(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(repo_id, kind, key, scope_branch, scope_module, scope_path_prefix)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_dependencies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
|
||||
dep_type TEXT NOT NULL,
|
||||
locator TEXT NOT NULL,
|
||||
is_hard INTEGER NOT NULL DEFAULT 1,
|
||||
baseline_hash TEXT,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_dependencies_entry_id
|
||||
ON knowledge_dependencies(entry_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_dependencies_locator
|
||||
ON knowledge_dependencies(dep_type, locator);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_aliases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
|
||||
alias TEXT NOT NULL,
|
||||
normalized_alias TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(entry_id, normalized_alias)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_aliases_normalized
|
||||
ON knowledge_aliases(normalized_alias);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
|
||||
to_entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
|
||||
relation TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_links_from
|
||||
ON knowledge_links(from_entry_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_links_to
|
||||
ON knowledge_links(to_entry_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
from_status TEXT,
|
||||
to_status TEXT,
|
||||
reason TEXT,
|
||||
evidence TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_events_entry_id_created_at
|
||||
ON knowledge_events(entry_id, created_at);
|
||||
`
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type RepoState struct {
|
||||
RootPath string
|
||||
Name string
|
||||
VCS string
|
||||
DefaultBranch string
|
||||
LastSeenBranch string
|
||||
LastSeenCommit string
|
||||
}
|
||||
|
||||
type DependencyInput struct {
|
||||
Type string
|
||||
Locator string
|
||||
IsHard bool
|
||||
BaselineHash string
|
||||
Note string
|
||||
}
|
||||
|
||||
type EntryInput struct {
|
||||
RepoRoot string
|
||||
Kind string
|
||||
Key string
|
||||
Title string
|
||||
Summary string
|
||||
DetailMD string
|
||||
Status string
|
||||
Confidence float64
|
||||
ScopeBranch string
|
||||
ScopeModule string
|
||||
ScopePathPrefix string
|
||||
SourcePath string
|
||||
SourceLine int
|
||||
VerifiedAt string
|
||||
VerifiedOnCommit string
|
||||
LastCheckedAt string
|
||||
LastCheckedCommit string
|
||||
StaleReason string
|
||||
Aliases []string
|
||||
Dependencies []DependencyInput
|
||||
}
|
||||
|
||||
type SearchParams struct {
|
||||
Query string
|
||||
RepoFilter string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
ID int64
|
||||
RepoPath string
|
||||
Kind string
|
||||
Key string
|
||||
Title string
|
||||
Status string
|
||||
Summary string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
type ListParams struct {
|
||||
RepoFilter string
|
||||
Kind string
|
||||
Status string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type EntryListItem struct {
|
||||
ID int64
|
||||
RepoPath string
|
||||
Kind string
|
||||
Key string
|
||||
Title string
|
||||
Status string
|
||||
Summary string
|
||||
VerifiedOnCommit string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type EntryRef struct {
|
||||
ID int64
|
||||
RepoRoot string
|
||||
Kind string
|
||||
Key string
|
||||
}
|
||||
|
||||
type EventRecord struct {
|
||||
ID int64
|
||||
EventType string
|
||||
FromStatus string
|
||||
ToStatus string
|
||||
Reason string
|
||||
Evidence string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type VerifyCandidate struct {
|
||||
ID int64
|
||||
RepoPath string
|
||||
Kind string
|
||||
Key string
|
||||
Title string
|
||||
Status string
|
||||
VerifiedOnCommit string
|
||||
Dependencies []DependencyInput
|
||||
}
|
||||
|
||||
type RepoInfo struct {
|
||||
Path string
|
||||
Name string
|
||||
EntryCount int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) Init(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, schema)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertRepo(ctx context.Context, state RepoState) (int64, error) {
|
||||
now := nowUTC()
|
||||
if strings.TrimSpace(state.RootPath) == "" {
|
||||
return 0, fmt.Errorf("repo root path is required")
|
||||
}
|
||||
if strings.TrimSpace(state.Name) == "" {
|
||||
state.Name = filepath.Base(state.RootPath)
|
||||
}
|
||||
if strings.TrimSpace(state.VCS) == "" {
|
||||
state.VCS = "git"
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO repos(name, root_path, vcs, default_branch, last_seen_branch, last_seen_commit, last_sync_at, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(root_path) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
vcs = excluded.vcs,
|
||||
default_branch = excluded.default_branch,
|
||||
last_seen_branch = excluded.last_seen_branch,
|
||||
last_seen_commit = excluded.last_seen_commit,
|
||||
last_sync_at = excluded.last_sync_at,
|
||||
updated_at = excluded.updated_at
|
||||
`, state.Name, state.RootPath, state.VCS, nullable(state.DefaultBranch), nullable(state.LastSeenBranch), nullable(state.LastSeenCommit), now, now, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var repoID int64
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT id FROM repos WHERE root_path = ?`, state.RootPath).Scan(&repoID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return repoID, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertEntry(ctx context.Context, in EntryInput) (int64, error) {
|
||||
if strings.TrimSpace(in.RepoRoot) == "" {
|
||||
return 0, fmt.Errorf("repo root is required")
|
||||
}
|
||||
if strings.TrimSpace(in.Kind) == "" {
|
||||
return 0, fmt.Errorf("kind is required")
|
||||
}
|
||||
if strings.TrimSpace(in.Key) == "" {
|
||||
return 0, fmt.Errorf("key is required")
|
||||
}
|
||||
if strings.TrimSpace(in.Summary) == "" {
|
||||
return 0, fmt.Errorf("summary is required")
|
||||
}
|
||||
if in.Confidence <= 0 {
|
||||
in.Confidence = 0.7
|
||||
}
|
||||
if strings.TrimSpace(in.Status) == "" {
|
||||
in.Status = "draft"
|
||||
}
|
||||
if strings.TrimSpace(in.Title) == "" {
|
||||
in.Title = in.Key
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var repoID int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM repos WHERE root_path = ?`, in.RepoRoot).Scan(&repoID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, fmt.Errorf("repo %s is not registered", in.RepoRoot)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := nowUTC()
|
||||
var entryID int64
|
||||
var oldStatus sql.NullString
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT id, status
|
||||
FROM knowledge_entries
|
||||
WHERE repo_id = ?
|
||||
AND kind = ?
|
||||
AND key = ?
|
||||
AND scope_branch IS ?
|
||||
AND scope_module IS ?
|
||||
AND scope_path_prefix IS ?
|
||||
`, repoID, in.Kind, in.Key, nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix)).Scan(&entryID, &oldStatus)
|
||||
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
eventType := "created"
|
||||
fromStatus := ""
|
||||
if err == sql.ErrNoRows {
|
||||
res, execErr := tx.ExecContext(ctx, `
|
||||
INSERT INTO knowledge_entries(
|
||||
repo_id, kind, key, title, summary, detail_md, status, confidence,
|
||||
scope_branch, scope_module, scope_path_prefix, source_path, source_line,
|
||||
verified_at, verified_on_commit, last_checked_at, last_checked_commit,
|
||||
stale_reason, created_at, updated_at
|
||||
)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, repoID, in.Kind, in.Key, in.Title, in.Summary, in.DetailMD, in.Status, in.Confidence,
|
||||
nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix), nullable(in.SourcePath), nullableInt(in.SourceLine),
|
||||
nullable(in.VerifiedAt), nullable(in.VerifiedOnCommit), nullable(in.LastCheckedAt), nullable(in.LastCheckedCommit),
|
||||
nullable(in.StaleReason), now, now)
|
||||
if execErr != nil {
|
||||
return 0, execErr
|
||||
}
|
||||
entryID, err = res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
} else {
|
||||
eventType = "updated"
|
||||
fromStatus = oldStatus.String
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE knowledge_entries
|
||||
SET title = ?, summary = ?, detail_md = ?, status = ?, confidence = ?,
|
||||
scope_branch = ?, scope_module = ?, scope_path_prefix = ?,
|
||||
source_path = ?, source_line = ?, verified_at = ?, verified_on_commit = ?,
|
||||
last_checked_at = ?, last_checked_commit = ?, stale_reason = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, in.Title, in.Summary, in.DetailMD, in.Status, in.Confidence,
|
||||
nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix),
|
||||
nullable(in.SourcePath), nullableInt(in.SourceLine), nullable(in.VerifiedAt), nullable(in.VerifiedOnCommit),
|
||||
nullable(in.LastCheckedAt), nullable(in.LastCheckedCommit), nullable(in.StaleReason), now, entryID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM knowledge_aliases WHERE entry_id = ?`, entryID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM knowledge_dependencies WHERE entry_id = ?`, entryID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, alias := range dedupeStrings(in.Aliases) {
|
||||
if strings.TrimSpace(alias) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO knowledge_aliases(entry_id, alias, normalized_alias, created_at)
|
||||
VALUES(?, ?, ?, ?)
|
||||
`, entryID, alias, normalizeAlias(alias), now); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, dep := range in.Dependencies {
|
||||
if strings.TrimSpace(dep.Type) == "" || strings.TrimSpace(dep.Locator) == "" {
|
||||
continue
|
||||
}
|
||||
isHard := 0
|
||||
if dep.IsHard {
|
||||
isHard = 1
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO knowledge_dependencies(entry_id, dep_type, locator, is_hard, baseline_hash, note, created_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
`, entryID, dep.Type, dep.Locator, isHard, nullable(dep.BaselineHash), nullable(dep.Note), now); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO knowledge_events(entry_id, event_type, from_status, to_status, reason, evidence, created_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
`, entryID, eventType, nullable(fromStatus), nullable(in.Status), nullable(in.StaleReason), nullable(eventEvidence(in)), now); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return entryID, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ImportDocument(ctx context.Context, doc brief.Document, repoState RepoState) error {
|
||||
repoState.RootPath = doc.RepoPath
|
||||
if _, err := s.UpsertRepo(ctx, repoState); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, sec := range doc.Sections {
|
||||
key := buildImportedKey(doc.DocPath, sec.Heading, sec.Ordinal)
|
||||
entry := EntryInput{
|
||||
RepoRoot: doc.RepoPath,
|
||||
Kind: classifyImportedKind(doc.Kind, sec.Heading),
|
||||
Key: key,
|
||||
Title: sec.Heading,
|
||||
Summary: sectionSummary(sec.Body),
|
||||
DetailMD: sec.Body,
|
||||
Status: "confirmed",
|
||||
Confidence: 0.8,
|
||||
SourcePath: doc.DocPath,
|
||||
VerifiedAt: nowUTC(),
|
||||
VerifiedOnCommit: repoState.LastSeenCommit,
|
||||
Dependencies: []DependencyInput{{
|
||||
Type: "file",
|
||||
Locator: doc.DocPath,
|
||||
IsHard: true,
|
||||
Note: "Imported from markdown knowledge source",
|
||||
}},
|
||||
}
|
||||
if _, err := s.UpsertEntry(ctx, entry); err != nil {
|
||||
return fmt.Errorf("import section %s: %w", sec.Heading, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Search(ctx context.Context, params SearchParams) ([]SearchResult, error) {
|
||||
limit := params.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
terms := dedupeStrings(strings.Fields(strings.ToLower(params.Query)))
|
||||
if len(terms) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT DISTINCT
|
||||
e.id,
|
||||
r.root_path,
|
||||
e.kind,
|
||||
e.key,
|
||||
e.title,
|
||||
e.status,
|
||||
e.summary,
|
||||
e.detail_md
|
||||
FROM knowledge_entries e
|
||||
JOIN repos r ON r.id = e.repo_id
|
||||
LEFT JOIN knowledge_aliases a ON a.entry_id = e.id
|
||||
WHERE 1 = 1
|
||||
`
|
||||
args := make([]any, 0, len(terms)*5+2)
|
||||
if params.RepoFilter != "" {
|
||||
query += ` AND r.root_path LIKE ?`
|
||||
args = append(args, "%"+params.RepoFilter+"%")
|
||||
}
|
||||
for _, term := range terms {
|
||||
query += ` AND (
|
||||
LOWER(e.key) LIKE ?
|
||||
OR LOWER(e.title) LIKE ?
|
||||
OR LOWER(e.summary) LIKE ?
|
||||
OR LOWER(e.detail_md) LIKE ?
|
||||
OR LOWER(COALESCE(a.normalized_alias, '')) LIKE ?
|
||||
)`
|
||||
like := "%" + term + "%"
|
||||
args = append(args, like, like, like, like, like)
|
||||
}
|
||||
query += ` ORDER BY r.root_path, e.kind, e.key LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []SearchResult
|
||||
for rows.Next() {
|
||||
var res SearchResult
|
||||
var detail string
|
||||
if err := rows.Scan(&res.ID, &res.RepoPath, &res.Kind, &res.Key, &res.Title, &res.Status, &res.Summary, &detail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Snippet = makeSnippet(res.Summary+"\n"+detail, terms)
|
||||
results = append(results, res)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListRepos(ctx context.Context) ([]RepoInfo, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT r.root_path, r.name, COUNT(e.id) AS entry_count, r.updated_at
|
||||
FROM repos r
|
||||
LEFT JOIN knowledge_entries e ON e.repo_id = r.id
|
||||
GROUP BY r.id
|
||||
ORDER BY r.root_path
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var repos []RepoInfo
|
||||
for rows.Next() {
|
||||
var repo RepoInfo
|
||||
var updated string
|
||||
if err := rows.Scan(&repo.Path, &repo.Name, &repo.EntryCount, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repo.UpdatedAt, err = time.Parse(time.RFC3339, updated)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse repo updated_at: %w", err)
|
||||
}
|
||||
repos = append(repos, repo)
|
||||
}
|
||||
return repos, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListEntries(ctx context.Context, params ListParams) ([]EntryListItem, error) {
|
||||
limit := params.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at
|
||||
FROM knowledge_entries e
|
||||
JOIN repos r ON r.id = e.repo_id
|
||||
WHERE 1 = 1
|
||||
`
|
||||
args := make([]any, 0, 4)
|
||||
if params.RepoFilter != "" {
|
||||
query += ` AND r.root_path LIKE ?`
|
||||
args = append(args, "%"+params.RepoFilter+"%")
|
||||
}
|
||||
if params.Kind != "" {
|
||||
query += ` AND e.kind = ?`
|
||||
args = append(args, params.Kind)
|
||||
}
|
||||
if params.Status != "" {
|
||||
query += ` AND e.status = ?`
|
||||
args = append(args, params.Status)
|
||||
}
|
||||
query += ` ORDER BY r.root_path, e.kind, e.key LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []EntryListItem
|
||||
for rows.Next() {
|
||||
var item EntryListItem
|
||||
var updated string
|
||||
if err := rows.Scan(&item.ID, &item.RepoPath, &item.Kind, &item.Key, &item.Title, &item.Status, &item.Summary, &item.VerifiedOnCommit, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.UpdatedAt, err = time.Parse(time.RFC3339, updated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ResolveEntry(ctx context.Context, ref EntryRef) (EntryListItem, error) {
|
||||
if ref.ID > 0 {
|
||||
return s.getEntryByID(ctx, ref.ID)
|
||||
}
|
||||
if strings.TrimSpace(ref.RepoRoot) == "" || strings.TrimSpace(ref.Kind) == "" || strings.TrimSpace(ref.Key) == "" {
|
||||
return EntryListItem{}, fmt.Errorf("either id or repo+kind+key is required")
|
||||
}
|
||||
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at
|
||||
FROM knowledge_entries e
|
||||
JOIN repos r ON r.id = e.repo_id
|
||||
WHERE r.root_path = ? AND e.kind = ? AND e.key = ?
|
||||
ORDER BY e.updated_at DESC
|
||||
LIMIT 1
|
||||
`, ref.RepoRoot, ref.Kind, ref.Key)
|
||||
return scanEntryListItem(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListEvents(ctx context.Context, entryID int64, limit int) ([]EventRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, event_type, COALESCE(from_status, ''), COALESCE(to_status, ''), COALESCE(reason, ''), COALESCE(evidence, ''), created_at
|
||||
FROM knowledge_events
|
||||
WHERE entry_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`, entryID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []EventRecord
|
||||
for rows.Next() {
|
||||
var ev EventRecord
|
||||
var created string
|
||||
if err := rows.Scan(&ev.ID, &ev.EventType, &ev.FromStatus, &ev.ToStatus, &ev.Reason, &ev.Evidence, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ev.CreatedAt, err = time.Parse(time.RFC3339, created)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, ev)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) AddLink(ctx context.Context, fromEntryID, toEntryID int64, relation string) error {
|
||||
if fromEntryID <= 0 || toEntryID <= 0 {
|
||||
return fmt.Errorf("both entry ids are required")
|
||||
}
|
||||
if strings.TrimSpace(relation) == "" {
|
||||
return fmt.Errorf("relation is required")
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO knowledge_links(from_entry_id, to_entry_id, relation, created_at)
|
||||
VALUES(?, ?, ?, ?)
|
||||
`, fromEntryID, toEntryID, relation, nowUTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListVerifyCandidates(ctx context.Context, repoRoot string) ([]VerifyCandidate, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, COALESCE(e.verified_on_commit, '')
|
||||
FROM knowledge_entries e
|
||||
JOIN repos r ON r.id = e.repo_id
|
||||
WHERE r.root_path = ? AND e.status IN ('confirmed', 'needs_review')
|
||||
ORDER BY e.kind, e.key
|
||||
`, repoRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var candidates []VerifyCandidate
|
||||
for rows.Next() {
|
||||
var c VerifyCandidate
|
||||
if err := rows.Scan(&c.ID, &c.RepoPath, &c.Kind, &c.Key, &c.Title, &c.Status, &c.VerifiedOnCommit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deps, err := s.listDependencies(ctx, c.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Dependencies = deps
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
return candidates, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ApplyVerificationResult(ctx context.Context, entryID int64, currentCommit, nextStatus, reason string) error {
|
||||
item, err := s.getEntryByID(ctx, entryID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := nowUTC()
|
||||
eventType := ""
|
||||
switch {
|
||||
case item.Status != nextStatus && nextStatus == "needs_review":
|
||||
eventType = "downgraded"
|
||||
case item.Status != nextStatus && nextStatus == "stale":
|
||||
eventType = "marked_stale"
|
||||
case item.Status != nextStatus:
|
||||
eventType = "updated"
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE knowledge_entries
|
||||
SET status = ?, stale_reason = ?, last_checked_at = ?, last_checked_commit = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, nextStatus, nullable(reason), now, nullable(currentCommit), now, entryID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if eventType != "" {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO knowledge_events(entry_id, event_type, from_status, to_status, reason, evidence, created_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
`, entryID, eventType, item.Status, nextStatus, nullable(reason), nil, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func normalizeAlias(alias string) string {
|
||||
alias = strings.ToLower(strings.TrimSpace(alias))
|
||||
alias = strings.ReplaceAll(alias, "_", " ")
|
||||
alias = strings.ReplaceAll(alias, "-", " ")
|
||||
return strings.Join(strings.Fields(alias), " ")
|
||||
}
|
||||
|
||||
func buildImportedKey(docPath, heading string, ordinal int) string {
|
||||
base := strings.TrimSuffix(filepath.Base(docPath), filepath.Ext(docPath))
|
||||
if normalized := slug(heading); normalized != "" {
|
||||
return base + ":" + normalized
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", base, ordinal)
|
||||
}
|
||||
|
||||
func classifyImportedKind(docKind, heading string) string {
|
||||
lowerDoc := strings.ToLower(docKind)
|
||||
lowerHeading := strings.ToLower(heading)
|
||||
|
||||
switch lowerDoc {
|
||||
case "glossary":
|
||||
return "term"
|
||||
case "playbooks":
|
||||
return "entry"
|
||||
case "repo-brief":
|
||||
switch {
|
||||
case strings.Contains(lowerHeading, "module"):
|
||||
return "module"
|
||||
case strings.Contains(lowerHeading, "chain"):
|
||||
return "chain"
|
||||
case strings.Contains(lowerHeading, "danger"):
|
||||
return "danger"
|
||||
case strings.Contains(lowerHeading, "anchor"), strings.Contains(lowerHeading, "entry"):
|
||||
return "entry"
|
||||
case strings.Contains(lowerHeading, "command"), strings.Contains(lowerHeading, "verify"):
|
||||
return "command"
|
||||
default:
|
||||
return "decision"
|
||||
}
|
||||
default:
|
||||
return "decision"
|
||||
}
|
||||
}
|
||||
|
||||
func sectionSummary(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return "Imported knowledge section"
|
||||
}
|
||||
body = strings.ReplaceAll(body, "\n", " ")
|
||||
body = strings.Join(strings.Fields(body), " ")
|
||||
runes := []rune(body)
|
||||
if len(runes) > 160 {
|
||||
return string(runes[:160]) + " …"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func makeSnippet(body string, terms []string) string {
|
||||
body = strings.TrimSpace(strings.ReplaceAll(body, "\n", " "))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lower := strings.ToLower(body)
|
||||
pos := -1
|
||||
match := ""
|
||||
for _, term := range terms {
|
||||
idx := strings.Index(lower, term)
|
||||
if idx >= 0 && (pos == -1 || idx < pos) {
|
||||
pos = idx
|
||||
match = term
|
||||
}
|
||||
}
|
||||
if pos == -1 {
|
||||
if len(body) > 160 {
|
||||
return body[:160] + " …"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
start := pos - 50
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := pos + len(match) + 80
|
||||
if end > len(body) {
|
||||
end = len(body)
|
||||
}
|
||||
|
||||
snippet := strings.TrimSpace(body[start:end])
|
||||
if start > 0 {
|
||||
snippet = "… " + snippet
|
||||
}
|
||||
if end < len(body) {
|
||||
snippet += " …"
|
||||
}
|
||||
return snippet
|
||||
}
|
||||
|
||||
func eventEvidence(in EntryInput) string {
|
||||
payload := map[string]any{
|
||||
"source_path": in.SourcePath,
|
||||
"source_line": in.SourceLine,
|
||||
"dependencies": in.Dependencies,
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func nowUTC() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func nullable(value string) any {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func nullableInt(value int) any {
|
||||
if value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func slug(value string) string {
|
||||
value = normalizeAlias(value)
|
||||
return strings.ReplaceAll(value, " ", "-")
|
||||
}
|
||||
|
||||
func dedupeStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
var out []string
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := trimmed
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) getEntryByID(ctx context.Context, id int64) (EntryListItem, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at
|
||||
FROM knowledge_entries e
|
||||
JOIN repos r ON r.id = e.repo_id
|
||||
WHERE e.id = ?
|
||||
`, id)
|
||||
return scanEntryListItem(row)
|
||||
}
|
||||
|
||||
func scanEntryListItem(row *sql.Row) (EntryListItem, error) {
|
||||
var item EntryListItem
|
||||
var updated string
|
||||
err := row.Scan(&item.ID, &item.RepoPath, &item.Kind, &item.Key, &item.Title, &item.Status, &item.Summary, &item.VerifiedOnCommit, &updated)
|
||||
if err != nil {
|
||||
return EntryListItem{}, err
|
||||
}
|
||||
item.UpdatedAt, err = time.Parse(time.RFC3339, updated)
|
||||
if err != nil {
|
||||
return EntryListItem{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Store) listDependencies(ctx context.Context, entryID int64) ([]DependencyInput, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT dep_type, locator, is_hard, COALESCE(baseline_hash, ''), COALESCE(note, '')
|
||||
FROM knowledge_dependencies
|
||||
WHERE entry_id = ?
|
||||
ORDER BY id
|
||||
`, entryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var deps []DependencyInput
|
||||
for rows.Next() {
|
||||
var dep DependencyInput
|
||||
var hard int
|
||||
if err := rows.Scan(&dep.Type, &dep.Locator, &hard, &dep.BaselineHash, &dep.Note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dep.IsHard = hard == 1
|
||||
deps = append(deps, dep)
|
||||
}
|
||||
return deps, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"ai-workflow-skill/packages/repo-memory-runtime/internal/brief"
|
||||
)
|
||||
|
||||
func TestImportDocumentAndSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "briefs.db")
|
||||
st, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := st.Init(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
doc := brief.Document{
|
||||
RepoPath: "/tmp/zeus",
|
||||
DocPath: "docs/ai/repo-brief.md",
|
||||
Kind: "repo-brief",
|
||||
Title: "Zeus Repo Brief",
|
||||
Hash: "abc",
|
||||
Metadata: map[string]string{"repo": "zeus"},
|
||||
Sections: []brief.Section{
|
||||
{Heading: "Module Map", Level: 2, Ordinal: 1, Body: "AI insight lives in app/app and gateway."},
|
||||
{Heading: "Danger Zones", Level: 2, Ordinal: 2, Body: "Avoid shared libs first."},
|
||||
},
|
||||
}
|
||||
|
||||
if err := st.ImportDocument(ctx, doc, RepoState{
|
||||
RootPath: doc.RepoPath,
|
||||
Name: "zeus",
|
||||
VCS: "git",
|
||||
LastSeenBranch: "main",
|
||||
LastSeenCommit: "abc123",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results, err := st.Search(ctx, SearchParams{
|
||||
Query: "insight gateway",
|
||||
Limit: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected search results")
|
||||
}
|
||||
if got, want := results[0].Kind, "module"; got != want {
|
||||
t.Fatalf("kind = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := results[0].Status, "confirmed"; got != want {
|
||||
t.Fatalf("status = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertEntryWithAliasesAndDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "briefs.db")
|
||||
st, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := st.Init(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.UpsertRepo(ctx, RepoState{
|
||||
RootPath: "/tmp/cupid",
|
||||
Name: "cupid",
|
||||
VCS: "git",
|
||||
LastSeenBranch: "main",
|
||||
LastSeenCommit: "def456",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entryID, err := st.UpsertEntry(ctx, EntryInput{
|
||||
RepoRoot: "/tmp/cupid",
|
||||
Kind: "term",
|
||||
Key: "AITask",
|
||||
Title: "AITask",
|
||||
Summary: "Plan 内嵌任务结构,不是独立表",
|
||||
Status: "confirmed",
|
||||
Confidence: 0.95,
|
||||
SourcePath: "app/app/src/main/java/foo/AITask.java",
|
||||
SourceLine: 42,
|
||||
VerifiedAt: "2026-03-20T00:00:00Z",
|
||||
VerifiedOnCommit: "def456",
|
||||
Aliases: []string{"AI Task", "plan task"},
|
||||
Dependencies: []DependencyInput{
|
||||
{Type: "file", Locator: "app/app/src/main/java/foo/AITask.java", IsHard: true},
|
||||
{Type: "file", Locator: "proto/ai_insight.proto", IsHard: true},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entryID == 0 {
|
||||
t.Fatal("expected entry id")
|
||||
}
|
||||
|
||||
results, err := st.Search(ctx, SearchParams{
|
||||
Query: "plan task",
|
||||
RepoFilter: "cupid",
|
||||
Limit: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected alias search results")
|
||||
}
|
||||
if got, want := results[0].Key, "AITask"; got != want {
|
||||
t.Fatalf("key = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
items, err := st.ListEntries(ctx, ListParams{
|
||||
RepoFilter: "cupid",
|
||||
Kind: "term",
|
||||
Status: "confirmed",
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("list entries = %d, want 1", len(items))
|
||||
}
|
||||
|
||||
events, err := st.ListEvents(ctx, entryID, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Fatal("expected events")
|
||||
}
|
||||
if got, want := events[0].EventType, "created"; got != want {
|
||||
t.Fatalf("event type = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
secondID, err := st.UpsertEntry(ctx, EntryInput{
|
||||
RepoRoot: "/tmp/cupid",
|
||||
Kind: "chain",
|
||||
Key: "ai-insight.get",
|
||||
Title: "AI Insight Get",
|
||||
Summary: "gateway -> app service -> cache/db",
|
||||
Status: "confirmed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.AddLink(ctx, entryID, secondID, "related_to"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var linkCount int
|
||||
if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM knowledge_links WHERE from_entry_id = ? AND to_entry_id = ?`, entryID, secondID).Scan(&linkCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if linkCount != 1 {
|
||||
t.Fatalf("link count = %d, want 1", linkCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVerificationResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "briefs.db")
|
||||
st, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := st.Init(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.UpsertRepo(ctx, RepoState{
|
||||
RootPath: "/tmp/zeus",
|
||||
Name: "zeus",
|
||||
VCS: "git",
|
||||
LastSeenBranch: "main",
|
||||
LastSeenCommit: "abc123",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entryID, err := st.UpsertEntry(ctx, EntryInput{
|
||||
RepoRoot: "/tmp/zeus",
|
||||
Kind: "term",
|
||||
Key: "AITask",
|
||||
Title: "AITask",
|
||||
Summary: "Plan 内嵌任务结构,不是独立表",
|
||||
Status: "confirmed",
|
||||
VerifiedOnCommit: "abc123",
|
||||
Dependencies: []DependencyInput{
|
||||
{Type: "file", Locator: "foo.go", IsHard: true},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := st.ApplyVerificationResult(ctx, entryID, "def456", "needs_review", "dependency paths changed since verification"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err := st.ResolveEntry(ctx, EntryRef{ID: entryID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := entry.Status, "needs_review"; got != want {
|
||||
t.Fatalf("status = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
events, err := st.ListEvents(ctx, entryID, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) < 2 {
|
||||
t.Fatalf("events = %d, want at least 2", len(events))
|
||||
}
|
||||
if got, want := events[0].EventType, "downgraded"; got != want {
|
||||
t.Fatalf("latest event = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user