Rename repo-memory runtime artifacts
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/documents"
|
||||
"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", "repo-memory.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", "repo-memory.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 := documents.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", "repo-memory.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", "repo-memory.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", "repo-memory.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", "repo-memory.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", "repo-memory.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", "repo-memory.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", "repo-memory.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, `repo-memory: repo memory CLI
|
||||
|
||||
Usage:
|
||||
repo-memory init --db repo-memory.db
|
||||
repo-memory add --db repo-memory.db --repo /path/to/repo --kind term --key AITask --summary "..."
|
||||
repo-memory ingest --db repo-memory.db --repo /path/to/repo [--path docs/ai]
|
||||
repo-memory search --db repo-memory.db --query "actionCode fill" [--repo zeus]
|
||||
repo-memory list --db repo-memory.db [--repo zeus] [--kind term] [--status confirmed]
|
||||
repo-memory events --db repo-memory.db --id 1
|
||||
repo-memory link --db repo-memory.db --from-id 1 --to-id 2 --relation related_to
|
||||
repo-memory verify --db repo-memory.db [--repo /path/to/repo]
|
||||
repo-memory repos --db repo-memory.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
|
||||
}
|
||||
Reference in New Issue
Block a user