Add repo-memory integration tests
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAddRegistersRepoAndEntry(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
sourcePath := filepath.Join(fixture.RepoPath, "app/app/src/main/java/foo/AITask.java")
|
||||
writeFile(t, sourcePath, "class AITask {}\n")
|
||||
commitAll(t, fixture.RepoPath, "init")
|
||||
|
||||
addOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan 内嵌任务结构,不是独立表",
|
||||
"--status", "confirmed",
|
||||
"--source-path", sourcePath,
|
||||
"--source-line", "42",
|
||||
"--alias", "AI Task",
|
||||
"--dep", "file:"+sourcePath+":hard",
|
||||
)
|
||||
listOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
"--kind", "term",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
|
||||
if got, want := stringsTrim(addOut), "upserted entry 1 (term:AITask)"; got != want {
|
||||
t.Fatalf("add output = %q, want %q", got, want)
|
||||
}
|
||||
assertContains(t, listOut, "#1 [repo] term:AITask [confirmed]")
|
||||
assertContains(t, listOut, "Plan 内嵌任务结构,不是独立表")
|
||||
}
|
||||
|
||||
func TestAddUpdatesExistingEntryOnSameKindAndKey(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
writeFile(t, filepath.Join(fixture.RepoPath, "foo.txt"), "hello\n")
|
||||
commitAll(t, fixture.RepoPath, "init")
|
||||
|
||||
runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "初版摘要",
|
||||
"--status", "draft",
|
||||
)
|
||||
addOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "修订后的摘要",
|
||||
"--status", "confirmed",
|
||||
"--alias", "AI Task",
|
||||
)
|
||||
eventsOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"events",
|
||||
"--db", fixture.DBPath,
|
||||
"--id", "1",
|
||||
)
|
||||
listOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
"--kind", "term",
|
||||
)
|
||||
|
||||
if got, want := stringsTrim(addOut), "upserted entry 1 (term:AITask)"; got != want {
|
||||
t.Fatalf("second add output = %q, want %q", got, want)
|
||||
}
|
||||
assertContains(t, eventsOut, "updated (draft -> confirmed)")
|
||||
assertContains(t, eventsOut, "created (- -> draft)")
|
||||
assertContains(t, listOut, "修订后的摘要")
|
||||
}
|
||||
|
||||
func TestAddFailedValidationStillRegistersRepo(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
writeFile(t, filepath.Join(fixture.RepoPath, "foo.txt"), "hello\n")
|
||||
commitAll(t, fixture.RepoPath, "init")
|
||||
|
||||
_, stderr, exitCode := executeRepoMemoryCommand(
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--summary", "missing kind",
|
||||
)
|
||||
reposOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"repos",
|
||||
"--db", fixture.DBPath,
|
||||
)
|
||||
listOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("add exit code = %d, want 1\nstderr:\n%s", exitCode, stderr)
|
||||
}
|
||||
mustContain(t, stderr, "kind is required")
|
||||
mustContain(t, reposOut, fixture.RepoPath+" (0 entries, updated ")
|
||||
if got, want := stringsTrim(listOut), "no entries"; got != want {
|
||||
t.Fatalf("list output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEventsReadsHistoryByID(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "initial summary",
|
||||
"--status", "draft",
|
||||
)
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "revised summary",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
|
||||
eventsOut := runRepoMemoryCommand(t,
|
||||
"events",
|
||||
"--db", fixture.DBPath,
|
||||
"--id", "1",
|
||||
)
|
||||
|
||||
assertContains(t, eventsOut, "term:AITask [confirmed] #1")
|
||||
assertContains(t, eventsOut, "updated (draft -> confirmed)")
|
||||
assertContains(t, eventsOut, "created (- -> draft)")
|
||||
if strings.Index(eventsOut, "updated (draft -> confirmed)") > strings.Index(eventsOut, "created (- -> draft)") {
|
||||
t.Fatalf("expected newest-first event order, got %q", eventsOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsResolvesEntryByRepoKindKey(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan embedded task structure",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
|
||||
eventsOut := runRepoMemoryCommand(t,
|
||||
"events",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
)
|
||||
|
||||
assertContains(t, eventsOut, "term:AITask [confirmed] #1")
|
||||
assertContains(t, eventsOut, "created (- -> confirmed)")
|
||||
}
|
||||
|
||||
func TestEventsRejectsMissingEntrySelector(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
|
||||
_, stderr, exitCode := executeRepoMemoryCommand(
|
||||
"events",
|
||||
"--db", fixture.DBPath,
|
||||
)
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d with stderr %q", exitCode, stderr)
|
||||
}
|
||||
assertContains(t, stderr, "either --id or --repo+--kind+--key is required")
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
executeMu sync.Mutex
|
||||
commandStdout io.Writer = os.Stdout
|
||||
commandStderr io.Writer = os.Stderr
|
||||
)
|
||||
|
||||
func Execute(args []string, stdout, stderr io.Writer) int {
|
||||
executeMu.Lock()
|
||||
defer executeMu.Unlock()
|
||||
|
||||
prevStdout := commandStdout
|
||||
prevStderr := commandStderr
|
||||
commandStdout = stdout
|
||||
commandStderr = stderr
|
||||
defer func() {
|
||||
commandStdout = prevStdout
|
||||
commandStderr = prevStderr
|
||||
}()
|
||||
|
||||
if len(args) < 1 {
|
||||
usage()
|
||||
return 2
|
||||
}
|
||||
|
||||
if err := runCommand(args); err != nil {
|
||||
_, _ = fmt.Fprintln(commandStderr, err)
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func runCommand(args []string) error {
|
||||
switch args[0] {
|
||||
case "init":
|
||||
return runInit(args[1:])
|
||||
case "add":
|
||||
return runAdd(args[1:])
|
||||
case "ingest":
|
||||
return runIngest(args[1:])
|
||||
case "search":
|
||||
return runSearch(args[1:])
|
||||
case "list":
|
||||
return runList(args[1:])
|
||||
case "events":
|
||||
return runEvents(args[1:])
|
||||
case "link":
|
||||
return runLink(args[1:])
|
||||
case "verify":
|
||||
return runVerify(args[1:])
|
||||
case "repos":
|
||||
return runRepos(args[1:])
|
||||
default:
|
||||
usage()
|
||||
return fmt.Errorf("unknown command %s", args[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIngestImportsDocsAIMarkdown(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
writeFile(t, filepath.Join(fixture.RepoPath, "docs/ai/repo-memory.md"), "# Repo Memory\n\n## Module Map\n\n- gateway\n- app/app\n\n## Danger Zones\n\n- shared libs first\n")
|
||||
commitAll(t, fixture.RepoPath, "init")
|
||||
|
||||
ingestOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"ingest",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
)
|
||||
listOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
)
|
||||
|
||||
mustContain(t, ingestOut, "ingested 1 docs from "+fixture.RepoPath)
|
||||
mustContain(t, listOut, "module:repo-memory:module-map [confirmed]")
|
||||
mustContain(t, listOut, "danger:repo-memory:danger-zones [confirmed]")
|
||||
}
|
||||
|
||||
func TestIngestRejectsWhenNoMarkdownFound(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
runGit(t, fixture.RepoPath, "commit", "--allow-empty", "-m", "init")
|
||||
if err := os.MkdirAll(filepath.Join(fixture.RepoPath, "docs", "ai"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir docs/ai: %v", err)
|
||||
}
|
||||
|
||||
_, stderr, exitCode := executeRepoMemoryCommand(
|
||||
"ingest",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("ingest exit code = %d, want 1\nstderr:\n%s", exitCode, stderr)
|
||||
}
|
||||
mustContain(t, stderr, "no markdown files found under "+filepath.Join(fixture.RepoPath, "docs", "ai"))
|
||||
}
|
||||
|
||||
func TestIngestImportsHeadinglessMarkdownAsSingleEntry(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
writeFile(t, filepath.Join(fixture.RepoPath, "docs/ai/repo-memory.md"), "This repository keeps AI memory notes near docs/ai.\nGateway owns ingress and app/app owns orchestration.\n")
|
||||
commitAll(t, fixture.RepoPath, "init")
|
||||
|
||||
ingestOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"ingest",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
)
|
||||
listOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
)
|
||||
searchOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"search",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
"--query", "Gateway orchestration",
|
||||
)
|
||||
|
||||
mustContain(t, ingestOut, "ingested 1 docs from "+fixture.RepoPath)
|
||||
mustContain(t, listOut, "decision:repo-memory:overview [confirmed]")
|
||||
mustContain(t, searchOut, "decision:repo-memory:overview")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInitCreatesSchemaOnEmptyDB(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "repo-memory.db")
|
||||
|
||||
stdout := runRepoMemoryCommand(t, "init", "--db", dbPath)
|
||||
|
||||
if got, want := stringsTrim(stdout), "initialized "+dbPath; got != want {
|
||||
t.Fatalf("init output = %q, want %q", got, want)
|
||||
}
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
t.Fatalf("stat db %q: %v", dbPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitIsIdempotentOnExistingDB(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "repo-memory.db")
|
||||
|
||||
first := runRepoMemoryCommand(t, "init", "--db", dbPath)
|
||||
second := runRepoMemoryCommand(t, "init", "--db", dbPath)
|
||||
|
||||
if got, want := stringsTrim(first), "initialized "+dbPath; got != want {
|
||||
t.Fatalf("first init output = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := stringsTrim(second), "initialized "+dbPath; got != want {
|
||||
t.Fatalf("second init output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type repoMemoryFixture struct {
|
||||
TempDir string
|
||||
DBPath string
|
||||
RepoPath string
|
||||
}
|
||||
|
||||
func newRepoMemoryFixture(t *testing.T) repoMemoryFixture {
|
||||
t.Helper()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
return repoMemoryFixture{
|
||||
TempDir: tempDir,
|
||||
DBPath: filepath.Join(tempDir, "repo-memory.db"),
|
||||
RepoPath: filepath.Join(tempDir, "repo"),
|
||||
}
|
||||
}
|
||||
|
||||
func runRepoMemoryCommand(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
stdout, stderr, exitCode := executeRepoMemoryCommand(args...)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("execute repo-memory command %v: exit=%d\nstderr:\n%s\nstdout:\n%s", args, exitCode, stderr, stdout)
|
||||
}
|
||||
|
||||
return stdout
|
||||
}
|
||||
|
||||
func executeRepoMemoryCommand(args ...string) (string, string, int) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := Execute(args, &stdout, &stderr)
|
||||
return stdout.String(), stderr.String(), exitCode
|
||||
}
|
||||
|
||||
func initRepoMemoryTestDB(t *testing.T) repoMemoryFixture {
|
||||
t.Helper()
|
||||
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
return fixture
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func initGitRepo(t *testing.T, repoPath string) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.MkdirAll(repoPath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir repo %s: %v", repoPath, err)
|
||||
}
|
||||
runGit(t, repoPath, "init")
|
||||
runGit(t, repoPath, "config", "user.email", "test@example.com")
|
||||
runGit(t, repoPath, "config", "user.name", "Tester")
|
||||
}
|
||||
|
||||
func createNamedGitRepo(t *testing.T, name string, files map[string]string) string {
|
||||
t.Helper()
|
||||
|
||||
repoPath := filepath.Join(t.TempDir(), name)
|
||||
initGitRepo(t, repoPath)
|
||||
for relPath, content := range files {
|
||||
writeFile(t, filepath.Join(repoPath, relPath), content)
|
||||
}
|
||||
if len(files) > 0 {
|
||||
commitAll(t, repoPath, "init")
|
||||
} else {
|
||||
runGit(t, repoPath, "commit", "--allow-empty", "-m", "init")
|
||||
}
|
||||
return repoPath
|
||||
}
|
||||
|
||||
func commitAll(t *testing.T, repoPath, message string) {
|
||||
t.Helper()
|
||||
|
||||
runGit(t, repoPath, "add", ".")
|
||||
runGit(t, repoPath, "commit", "-m", message)
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, repoPath string, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, string(out))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func mustAbsPath(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
t.Fatalf("abs path %s: %v", path, err)
|
||||
}
|
||||
return absPath
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, got, want string) {
|
||||
t.Helper()
|
||||
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected %q to contain %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNotContains(t *testing.T, got, want string) {
|
||||
t.Helper()
|
||||
|
||||
if strings.Contains(got, want) {
|
||||
t.Fatalf("expected %q to not contain %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func mustContain(t *testing.T, got, want string) {
|
||||
t.Helper()
|
||||
assertContains(t, got, want)
|
||||
}
|
||||
|
||||
func mustNotContain(t *testing.T, got, want string) {
|
||||
t.Helper()
|
||||
assertNotContains(t, got, want)
|
||||
}
|
||||
|
||||
func mustWriteRepoFile(t *testing.T, repoPath, relPath, content string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(repoPath, relPath)
|
||||
writeFile(t, path, content)
|
||||
return path
|
||||
}
|
||||
|
||||
func queryStrings(t *testing.T, dbPath, query string, args ...any) []string {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite %s: %v", dbPath, err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("query sqlite %q: %v", query, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err := rows.Scan(&value); err != nil {
|
||||
t.Fatalf("scan sqlite row for %q: %v", query, err)
|
||||
}
|
||||
items = append(items, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate sqlite rows for %q: %v", query, err)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func queryInt(t *testing.T, dbPath, query string, args ...any) int {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite %s: %v", dbPath, err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var value int
|
||||
if err := db.QueryRow(query, args...).Scan(&value); err != nil {
|
||||
t.Fatalf("query row sqlite %q: %v", query, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLinkCreatesRelationBetweenEntries(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan embedded task structure",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "chain",
|
||||
"--key", "ai-insight.get",
|
||||
"--summary", "gateway -> app service -> cache/db",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
|
||||
linkOut := runRepoMemoryCommand(t,
|
||||
"link",
|
||||
"--db", fixture.DBPath,
|
||||
"--from-id", "1",
|
||||
"--to-id", "2",
|
||||
"--relation", "related_to",
|
||||
)
|
||||
assertContains(t, linkOut, "linked #1 -[related_to]-> #2")
|
||||
|
||||
relations := queryStrings(t, fixture.DBPath, `SELECT relation FROM knowledge_links WHERE from_entry_id = 1 AND to_entry_id = 2`)
|
||||
if len(relations) != 1 || relations[0] != "related_to" {
|
||||
t.Fatalf("relations = %#v, want [related_to]", relations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkRejectsMissingRelation(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan embedded task structure",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "chain",
|
||||
"--key", "ai-insight.get",
|
||||
"--summary", "gateway -> app service -> cache/db",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
|
||||
_, stderr, exitCode := executeRepoMemoryCommand(
|
||||
"link",
|
||||
"--db", fixture.DBPath,
|
||||
"--from-id", "1",
|
||||
"--to-id", "2",
|
||||
)
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d with stderr %q", exitCode, stderr)
|
||||
}
|
||||
assertContains(t, stderr, "relation is required")
|
||||
}
|
||||
|
||||
func TestLinkRejectsWhenEntryIDMissing(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan embedded task structure",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
|
||||
_, stderr, exitCode := executeRepoMemoryCommand(
|
||||
"link",
|
||||
"--db", fixture.DBPath,
|
||||
"--from-id", "1",
|
||||
"--relation", "related_to",
|
||||
)
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d with stderr %q", exitCode, stderr)
|
||||
}
|
||||
assertContains(t, stderr, "both entry ids are required")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListFiltersByKindAndStatus(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
writeFile(t, filepath.Join(fixture.RepoPath, "foo.txt"), "hello\n")
|
||||
commitAll(t, fixture.RepoPath, "init")
|
||||
|
||||
runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan 内嵌任务结构",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AIJob",
|
||||
"--summary", "后台任务封装",
|
||||
"--status", "draft",
|
||||
)
|
||||
runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "chain",
|
||||
"--key", "ai-insight.get",
|
||||
"--summary", "gateway -> app service -> cache/db",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
|
||||
listOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
"--kind", "term",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
|
||||
mustContain(t, listOut, "term:AITask [confirmed]")
|
||||
mustNotContain(t, listOut, "AIJob")
|
||||
mustNotContain(t, listOut, "chain:ai-insight.get")
|
||||
}
|
||||
|
||||
func TestListReturnsNoEntriesWhenEmpty(t *testing.T) {
|
||||
fixture := initRepoMemoryTestDB(t)
|
||||
|
||||
listOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
"--kind", "term",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
|
||||
if got, want := stringsTrim(listOut), "no entries"; got != want {
|
||||
t.Fatalf("list output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -15,52 +15,7 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
os.Exit(Execute(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
type stringSliceFlag []string
|
||||
@@ -76,6 +31,7 @@ func (s *stringSliceFlag) Set(value string) error {
|
||||
|
||||
func runInit(args []string) error {
|
||||
fs := flag.NewFlagSet("init", flag.ContinueOnError)
|
||||
fs.SetOutput(commandStderr)
|
||||
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -91,12 +47,13 @@ func runInit(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("initialized %s\n", *dbPath)
|
||||
_, _ = fmt.Fprintf(commandStdout, "initialized %s\n", *dbPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runIngest(args []string) error {
|
||||
fs := flag.NewFlagSet("ingest", flag.ContinueOnError)
|
||||
fs.SetOutput(commandStderr)
|
||||
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")
|
||||
@@ -156,12 +113,13 @@ func runIngest(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("ingested %d docs from %s\n", len(docs), absRepo)
|
||||
_, _ = fmt.Fprintf(commandStdout, "ingested %d docs from %s\n", len(docs), absRepo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAdd(args []string) error {
|
||||
fs := flag.NewFlagSet("add", flag.ContinueOnError)
|
||||
fs.SetOutput(commandStderr)
|
||||
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")
|
||||
@@ -237,12 +195,13 @@ func runAdd(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("upserted entry %d (%s:%s)\n", entryID, input.Kind, input.Key)
|
||||
_, _ = fmt.Fprintf(commandStdout, "upserted entry %d (%s:%s)\n", entryID, input.Kind, input.Key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSearch(args []string) error {
|
||||
fs := flag.NewFlagSet("search", flag.ContinueOnError)
|
||||
fs.SetOutput(commandStderr)
|
||||
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)")
|
||||
@@ -269,20 +228,21 @@ func runSearch(args []string) error {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
fmt.Println("no results")
|
||||
_, _ = fmt.Fprintln(commandStdout, "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)
|
||||
_, _ = fmt.Fprintf(commandStdout, "%d. [%s] %s:%s [%s]\n", i+1, filepath.Base(r.RepoPath), r.Kind, r.Key, r.Status)
|
||||
_, _ = fmt.Fprintf(commandStdout, " %s\n", r.Title)
|
||||
_, _ = fmt.Fprintf(commandStdout, " %s\n", r.Snippet)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRepos(args []string) error {
|
||||
fs := flag.NewFlagSet("repos", flag.ContinueOnError)
|
||||
fs.SetOutput(commandStderr)
|
||||
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -299,18 +259,19 @@ func runRepos(args []string) error {
|
||||
return err
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
fmt.Println("no repos")
|
||||
_, _ = fmt.Fprintln(commandStdout, "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))
|
||||
_, _ = fmt.Fprintf(commandStdout, "- %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)
|
||||
fs.SetOutput(commandStderr)
|
||||
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")
|
||||
@@ -336,19 +297,20 @@ func runList(args []string) error {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
fmt.Println("no entries")
|
||||
_, _ = fmt.Fprintln(commandStdout, "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)
|
||||
_, _ = fmt.Fprintf(commandStdout, "- #%d [%s] %s:%s [%s]\n", item.ID, filepath.Base(item.RepoPath), item.Kind, item.Key, item.Status)
|
||||
_, _ = fmt.Fprintf(commandStdout, " %s\n", item.Summary)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEvents(args []string) error {
|
||||
fs := flag.NewFlagSet("events", flag.ContinueOnError)
|
||||
fs.SetOutput(commandStderr)
|
||||
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")
|
||||
@@ -378,19 +340,19 @@ func runEvents(args []string) error {
|
||||
return err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
fmt.Println("no events")
|
||||
_, _ = fmt.Fprintln(commandStdout, "no events")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s:%s [%s] #%d\n", entry.Kind, entry.Key, entry.Status, entry.ID)
|
||||
_, _ = fmt.Fprintf(commandStdout, "%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)
|
||||
_, _ = fmt.Fprintln(commandStdout, line)
|
||||
if ev.Reason != "" {
|
||||
fmt.Printf(" reason: %s\n", ev.Reason)
|
||||
_, _ = fmt.Fprintf(commandStdout, " reason: %s\n", ev.Reason)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -398,6 +360,7 @@ func runEvents(args []string) error {
|
||||
|
||||
func runLink(args []string) error {
|
||||
fs := flag.NewFlagSet("link", flag.ContinueOnError)
|
||||
fs.SetOutput(commandStderr)
|
||||
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")
|
||||
@@ -416,12 +379,13 @@ func runLink(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("linked #%d -[%s]-> #%d\n", *fromID, *relation, *toID)
|
||||
_, _ = fmt.Fprintf(commandStdout, "linked #%d -[%s]-> #%d\n", *fromID, *relation, *toID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runVerify(args []string) error {
|
||||
fs := flag.NewFlagSet("verify", flag.ContinueOnError)
|
||||
fs.SetOutput(commandStderr)
|
||||
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 {
|
||||
@@ -451,14 +415,14 @@ func runVerify(args []string) error {
|
||||
}
|
||||
}
|
||||
if len(repoRoots) == 0 {
|
||||
fmt.Println("no repos")
|
||||
_, _ = fmt.Fprintln(commandStdout, "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)
|
||||
_, _ = fmt.Fprintf(commandStdout, "%s: skipped (not a git repo or no HEAD)\n", repoRoot)
|
||||
continue
|
||||
}
|
||||
if _, err := st.UpsertRepo(context.Background(), store.RepoState{
|
||||
@@ -494,14 +458,14 @@ func runVerify(args []string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s: verified %d entries, %d downgraded, %d stale\n", repoRoot, len(candidates), changedCount, staleCount)
|
||||
_, _ = fmt.Fprintf(commandStdout, "%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
|
||||
_, _ = fmt.Fprintf(commandStderr, `repo-memory: repo memory CLI
|
||||
|
||||
Usage:
|
||||
repo-memory init --db repo-memory.db
|
||||
@@ -516,11 +480,6 @@ Usage:
|
||||
`)
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
type gitState struct {
|
||||
branch string
|
||||
commit string
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReposListsTrackedRepositories(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
cupidRepo := filepath.Join(t.TempDir(), "cupid-service")
|
||||
marsRepo := filepath.Join(t.TempDir(), "mars-service")
|
||||
initGitRepo(t, cupidRepo)
|
||||
initGitRepo(t, marsRepo)
|
||||
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", cupidRepo,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan embedded task structure",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", marsRepo,
|
||||
"--kind", "term",
|
||||
"--key", "DeployPlan",
|
||||
"--summary", "Release plan",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
|
||||
reposOut := runRepoMemoryCommand(t,
|
||||
"repos",
|
||||
"--db", fixture.DBPath,
|
||||
)
|
||||
assertContains(t, reposOut, cupidRepo+" (1 entries, updated ")
|
||||
assertContains(t, reposOut, marsRepo+" (1 entries, updated ")
|
||||
}
|
||||
|
||||
func TestReposPrintsNoReposWhenEmpty(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
|
||||
reposOut := runRepoMemoryCommand(t,
|
||||
"repos",
|
||||
"--db", fixture.DBPath,
|
||||
)
|
||||
if stringsTrim(reposOut) != "no repos" {
|
||||
t.Fatalf("expected no repos, got %q", reposOut)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchReturnsMatchingEntrySnippet(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
writeFile(t, fixture.RepoPath+"/foo.txt", "hello\n")
|
||||
commitAll(t, fixture.RepoPath, "init")
|
||||
|
||||
runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "chain",
|
||||
"--key", "ai-insight.get",
|
||||
"--summary", "gateway -> app service -> cache/db",
|
||||
"--detail", "The AI insight read path goes through gateway before app service reaches cache and database.",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
searchOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"search",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
"--query", "insight gateway",
|
||||
)
|
||||
|
||||
mustContain(t, searchOut, "1. [repo] chain:ai-insight.get [confirmed]")
|
||||
mustContain(t, searchOut, "gateway")
|
||||
}
|
||||
|
||||
func TestSearchMatchesAliasWithRepoFilter(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
cupid := newRepoMemoryFixture(t)
|
||||
cupid.RepoPath = filepath.Join(cupid.TempDir, "cupid-service")
|
||||
mars := newRepoMemoryFixture(t)
|
||||
mars.RepoPath = filepath.Join(mars.TempDir, "mars-service")
|
||||
initGitRepo(t, cupid.RepoPath)
|
||||
initGitRepo(t, mars.RepoPath)
|
||||
writeFile(t, filepath.Join(cupid.RepoPath, "foo.txt"), "hello\n")
|
||||
writeFile(t, filepath.Join(mars.RepoPath, "bar.txt"), "hello\n")
|
||||
commitAll(t, cupid.RepoPath, "init")
|
||||
commitAll(t, mars.RepoPath, "init")
|
||||
|
||||
runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", cupid.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan 内嵌任务结构",
|
||||
"--status", "confirmed",
|
||||
"--alias", "plan task",
|
||||
)
|
||||
runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", mars.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "DeployPlan",
|
||||
"--summary", "发布计划",
|
||||
"--status", "confirmed",
|
||||
"--alias", "release plan",
|
||||
)
|
||||
searchOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"search",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "cupid",
|
||||
"--query", "plan task",
|
||||
)
|
||||
|
||||
mustContain(t, searchOut, "[cupid-service] term:AITask [confirmed]")
|
||||
mustNotContain(t, searchOut, "[mars-service]")
|
||||
}
|
||||
|
||||
func TestSearchReturnsNoResultsWhenEmpty(t *testing.T) {
|
||||
fixture := initRepoMemoryTestDB(t)
|
||||
|
||||
searchOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"search",
|
||||
"--db", fixture.DBPath,
|
||||
"--query", "missing term",
|
||||
)
|
||||
|
||||
if got, want := stringsTrim(searchOut), "no results"; got != want {
|
||||
t.Fatalf("search output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchRejectsMissingQuery(t *testing.T) {
|
||||
fixture := initRepoMemoryTestDB(t)
|
||||
|
||||
_, stderr, exitCode := executeRepoMemoryCommand(
|
||||
"search",
|
||||
"--db", fixture.DBPath,
|
||||
)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("search exit code = %d, want 1\nstderr:\n%s", exitCode, stderr)
|
||||
}
|
||||
mustContain(t, stderr, "--query is required")
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifyDowngradesChangedFileDependency(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
filePath := filepath.Join(fixture.RepoPath, "foo.txt")
|
||||
writeFile(t, filePath, "hello\n")
|
||||
commitAll(t, fixture.RepoPath, "add foo")
|
||||
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan embedded task structure",
|
||||
"--status", "confirmed",
|
||||
"--dep", "file:"+filePath+":hard",
|
||||
)
|
||||
if err := os.WriteFile(filePath, []byte("changed\n"), 0o644); err != nil {
|
||||
t.Fatalf("mutate dependency file: %v", err)
|
||||
}
|
||||
|
||||
verifyOut := runRepoMemoryCommand(t,
|
||||
"verify",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
)
|
||||
assertContains(t, verifyOut, "verified 1 entries, 1 downgraded, 0 stale")
|
||||
|
||||
listOut := runRepoMemoryCommand(t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
"--status", "needs_review",
|
||||
)
|
||||
assertContains(t, listOut, "term:AITask [needs_review]")
|
||||
|
||||
eventsOut := runRepoMemoryCommand(t,
|
||||
"events",
|
||||
"--db", fixture.DBPath,
|
||||
"--id", "1",
|
||||
)
|
||||
assertContains(t, eventsOut, "downgraded (confirmed -> needs_review)")
|
||||
}
|
||||
|
||||
func TestVerifyMarksMissingHardDependencyStale(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
runGit(t, fixture.RepoPath, "commit", "--allow-empty", "-m", "init")
|
||||
missingPath := filepath.Join(fixture.RepoPath, "missing.txt")
|
||||
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan embedded task structure",
|
||||
"--status", "confirmed",
|
||||
"--dep", "file:"+missingPath+":hard",
|
||||
)
|
||||
|
||||
verifyOut := runRepoMemoryCommand(t,
|
||||
"verify",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
)
|
||||
assertContains(t, verifyOut, "verified 1 entries, 0 downgraded, 1 stale")
|
||||
|
||||
listOut := runRepoMemoryCommand(t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
"--status", "stale",
|
||||
)
|
||||
assertContains(t, listOut, "term:AITask [stale]")
|
||||
|
||||
eventsOut := runRepoMemoryCommand(t,
|
||||
"events",
|
||||
"--db", fixture.DBPath,
|
||||
"--id", "1",
|
||||
)
|
||||
assertContains(t, eventsOut, "marked_stale (confirmed -> stale)")
|
||||
}
|
||||
|
||||
func TestVerifyPrintsNoReposWhenEmpty(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
|
||||
verifyOut := runRepoMemoryCommand(t,
|
||||
"verify",
|
||||
"--db", fixture.DBPath,
|
||||
)
|
||||
if stringsTrim(verifyOut) != "no repos" {
|
||||
t.Fatalf("expected no repos, got %q", verifyOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySkipsExplicitRepoWithoutGitHead(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
repoPath := filepath.Join(t.TempDir(), "repo")
|
||||
writeFile(t, filepath.Join(repoPath, ".keep"), "")
|
||||
|
||||
verifyOut := runRepoMemoryCommand(t,
|
||||
"verify",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", repoPath,
|
||||
)
|
||||
assertContains(t, verifyOut, repoPath+": skipped (not a git repo or no HEAD)")
|
||||
|
||||
reposOut := runRepoMemoryCommand(t,
|
||||
"repos",
|
||||
"--db", fixture.DBPath,
|
||||
)
|
||||
if stringsTrim(reposOut) != "no repos" {
|
||||
t.Fatalf("expected no repos after skipped verify, got %q", reposOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDowngradesEntryMissingVerifiedOnCommit(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
repoPath := filepath.Join(t.TempDir(), "repo")
|
||||
if err := os.MkdirAll(repoPath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir repo: %v", err)
|
||||
}
|
||||
filePath := filepath.Join(repoPath, "foo.txt")
|
||||
if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("write dependency file: %v", err)
|
||||
}
|
||||
|
||||
runRepoMemoryCommand(t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", repoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Recorded before git init",
|
||||
"--status", "confirmed",
|
||||
"--dep", "file:"+filePath+":hard",
|
||||
)
|
||||
|
||||
runCmd(t, repoPath, "git", "init")
|
||||
runCmd(t, repoPath, "git", "config", "user.email", "test@example.com")
|
||||
runCmd(t, repoPath, "git", "config", "user.name", "Tester")
|
||||
runCmd(t, repoPath, "git", "add", ".")
|
||||
runCmd(t, repoPath, "git", "commit", "-m", "init")
|
||||
|
||||
verifyOut := runRepoMemoryCommand(t,
|
||||
"verify",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", repoPath,
|
||||
)
|
||||
assertContains(t, verifyOut, "verified 1 entries, 1 downgraded, 0 stale")
|
||||
|
||||
listOut := runRepoMemoryCommand(t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
"--status", "needs_review",
|
||||
)
|
||||
assertContains(t, listOut, "term:AITask [needs_review]")
|
||||
|
||||
eventsOut := runRepoMemoryCommand(t,
|
||||
"events",
|
||||
"--db", fixture.DBPath,
|
||||
"--id", "1",
|
||||
)
|
||||
assertContains(t, eventsOut, "downgraded (confirmed -> needs_review)")
|
||||
assertContains(t, eventsOut, "reason: missing verified_on_commit")
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkflowAddSearchEventsRoundtrip(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
evidencePath := filepath.Join(fixture.RepoPath, "app", "app", "src", "main", "java", "foo", "AITask.java")
|
||||
writeFile(t, evidencePath, "class AITask {}\n")
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
commitAll(t, fixture.RepoPath, "init")
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
|
||||
addOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan 内嵌任务结构,不是独立表",
|
||||
"--status", "confirmed",
|
||||
"--source-path", evidencePath,
|
||||
"--source-line", "42",
|
||||
"--alias", "AI Task",
|
||||
"--dep", "file:"+evidencePath+":hard",
|
||||
)
|
||||
searchOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"search",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
"--query", "AI Task",
|
||||
)
|
||||
eventsOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"events",
|
||||
"--db", fixture.DBPath,
|
||||
"--id", "1",
|
||||
)
|
||||
|
||||
assertContains(t, addOut, "upserted entry 1 (term:AITask)")
|
||||
assertContains(t, searchOut, "[repo] term:AITask [confirmed]")
|
||||
assertContains(t, eventsOut, "term:AITask [confirmed] #1")
|
||||
assertContains(t, eventsOut, "created")
|
||||
}
|
||||
|
||||
func TestWorkflowIngestSearchListAcrossSections(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
docPath := filepath.Join(fixture.RepoPath, "docs", "ai", "repo-memory.md")
|
||||
writeFile(t, docPath, strings.TrimSpace(`
|
||||
# Repo Memory
|
||||
|
||||
## Module Map
|
||||
|
||||
- gateway
|
||||
- app/app
|
||||
|
||||
## Danger Zones
|
||||
|
||||
- shared libs first
|
||||
`)+"\n")
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
commitAll(t, fixture.RepoPath, "init")
|
||||
|
||||
ingestOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"ingest",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
)
|
||||
searchOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"search",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
"--query", "gateway",
|
||||
)
|
||||
listOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
)
|
||||
|
||||
assertContains(t, ingestOut, "ingested 1 docs from "+fixture.RepoPath)
|
||||
assertContains(t, searchOut, "[repo] module:repo-memory:module-map [confirmed]")
|
||||
assertContains(t, listOut, "module:repo-memory:module-map [confirmed]")
|
||||
assertContains(t, listOut, "danger:repo-memory:danger-zones [confirmed]")
|
||||
}
|
||||
|
||||
func TestWorkflowAddLinkAndResolveRelatedEntry(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
evidencePath := filepath.Join(fixture.RepoPath, "docs", "term.md")
|
||||
writeFile(t, evidencePath, "AITask reference\n")
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
commitAll(t, fixture.RepoPath, "init")
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
|
||||
runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan 内嵌任务结构,不是独立表",
|
||||
"--status", "confirmed",
|
||||
"--source-path", evidencePath,
|
||||
)
|
||||
runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "chain",
|
||||
"--key", "ai-insight.get",
|
||||
"--summary", "gateway -> app service -> cache/db",
|
||||
"--status", "confirmed",
|
||||
)
|
||||
|
||||
linkOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"link",
|
||||
"--db", fixture.DBPath,
|
||||
"--from-id", "1",
|
||||
"--to-id", "2",
|
||||
"--relation", "related_to",
|
||||
)
|
||||
relations := queryStrings(
|
||||
t,
|
||||
fixture.DBPath,
|
||||
`SELECT relation FROM knowledge_links WHERE from_entry_id = ? AND to_entry_id = ?`,
|
||||
1,
|
||||
2,
|
||||
)
|
||||
eventsOne := runRepoMemoryCommand(t, "events", "--db", fixture.DBPath, "--id", "1")
|
||||
eventsTwo := runRepoMemoryCommand(t, "events", "--db", fixture.DBPath, "--id", "2")
|
||||
|
||||
assertContains(t, linkOut, "linked #1 -[related_to]-> #2")
|
||||
if len(relations) != 1 || relations[0] != "related_to" {
|
||||
t.Fatalf("expected one stored relation related_to, got %#v", relations)
|
||||
}
|
||||
assertContains(t, eventsOne, "term:AITask [confirmed] #1")
|
||||
assertContains(t, eventsTwo, "chain:ai-insight.get [confirmed] #2")
|
||||
}
|
||||
|
||||
func TestWorkflowVerifyDowngradesAfterRepoChange(t *testing.T) {
|
||||
fixture := newRepoMemoryFixture(t)
|
||||
evidencePath := filepath.Join(fixture.RepoPath, "foo.txt")
|
||||
writeFile(t, evidencePath, "hello\n")
|
||||
initGitRepo(t, fixture.RepoPath)
|
||||
commitAll(t, fixture.RepoPath, "init")
|
||||
runRepoMemoryCommand(t, "init", "--db", fixture.DBPath)
|
||||
|
||||
runRepoMemoryCommand(
|
||||
t,
|
||||
"add",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
"--kind", "term",
|
||||
"--key", "AITask",
|
||||
"--summary", "Plan 内嵌任务结构",
|
||||
"--status", "confirmed",
|
||||
"--dep", "file:"+evidencePath+":hard",
|
||||
)
|
||||
|
||||
writeFile(t, evidencePath, "changed\n")
|
||||
|
||||
verifyOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"verify",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", fixture.RepoPath,
|
||||
)
|
||||
listOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"list",
|
||||
"--db", fixture.DBPath,
|
||||
"--repo", "repo",
|
||||
)
|
||||
eventsOut := runRepoMemoryCommand(
|
||||
t,
|
||||
"events",
|
||||
"--db", fixture.DBPath,
|
||||
"--id", "1",
|
||||
)
|
||||
|
||||
assertContains(t, verifyOut, "verified 1 entries, 1 downgraded, 0 stale")
|
||||
assertContains(t, listOut, "term:AITask [needs_review]")
|
||||
assertContains(t, eventsOut, "downgraded (confirmed -> needs_review)")
|
||||
}
|
||||
@@ -674,22 +674,32 @@ func (s *Store) ListVerifyCandidates(ctx context.Context, repoRoot string) ([]Ve
|
||||
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 {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
deps, err := s.listDependencies(ctx, c.ID)
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range candidates {
|
||||
deps, err := s.listDependencies(ctx, candidates[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Dependencies = deps
|
||||
candidates = append(candidates, c)
|
||||
candidates[i].Dependencies = deps
|
||||
}
|
||||
return candidates, rows.Err()
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *Store) ApplyVerificationResult(ctx context.Context, entryID int64, currentCommit, nextStatus, reason string) error {
|
||||
|
||||
Reference in New Issue
Block a user